From 75ca2937df8b0d832bfbf4210103d2b82c318962 Mon Sep 17 00:00:00 2001 From: HemSoft Date: Mon, 26 Oct 2026 21:26:40 -0400 Subject: [PATCH 1/4] feat: show weekly SUS early from 1 h and firm at a shared 12 h maturity Every weekly window waited for 10% of its period (16h 48m) before it forecast, so SUS read n/a until then; only Antigravity used 12 hours (#263). All weeks now forecast and score firm SUS from WEEKLY_MATURITY (12 h), the usage.py and Pi dashboard guard. From EARLY_SUS (1 h) a week shows an uncolored "SUS n early" from usage so far; it never sets a forecast, status, risk, alert or fleet SUS. Widget feeds carry it as susEarly beside the firm sus. Five-hour and other periods keep their rules. Closes #276 Co-Authored-By: Claude Opus 5.5 --- crates/codexbar-app/src/dashboard.rs | 47 +++++- crates/codexbar-app/src/self_capture.rs | 5 + .../src/self_capture/claude_weekly.rs | 65 ++++++++ crates/codexbar-app/src/sus.rs | 43 ++++-- crates/codexbar-app/src/ui_tests.rs | 53 ++++++- crates/codexbar-app/src/widget_builder.rs | 8 +- crates/codexbar-app/src/widget_feed.rs | 26 ++++ crates/codexbar-app/src/widgets/cards.rs | 51 ++++++- crates/codexbar-core/src/sus.rs | 142 ++++++++++++++++-- crates/codexbar-providers/src/claude.rs | 41 +++++ crates/codexbar-providers/src/codex.rs | 34 +++++ .../codexbar-providers/src/gemini_coding.rs | 14 +- crates/codexbar-providers/src/muse.rs | 34 ++++- crates/codexbar-providers/src/pace.rs | 37 ++++- crates/codexbar-store/src/widgets.rs | 19 +++ docs/SUS.md | 22 ++- 16 files changed, 580 insertions(+), 61 deletions(-) create mode 100644 crates/codexbar-app/src/self_capture/claude_weekly.rs diff --git a/crates/codexbar-app/src/dashboard.rs b/crates/codexbar-app/src/dashboard.rs index a988b753..6b6e6043 100644 --- a/crates/codexbar-app/src/dashboard.rs +++ b/crates/codexbar-app/src/dashboard.rs @@ -1439,6 +1439,46 @@ impl Dashboard { cx.notify(); } + /// A synthetic Claude usage payload through the real parser, `age` into a week that is 8% used, for the early + /// and firm SUS states without live accounts. + #[cfg(any(test, feature = "capture"))] + pub(crate) fn capture_claude_weekly(&mut self, age: Duration, cx: &mut Context) { + use codexbar_core::{AccountId, Provider}; + assert!(matches!(self.source, DataSource::Demo)); + let start: DateTime = "2026-10-26T09:00:00Z".parse().unwrap(); + let now = start + age; + crate::sus::capture_time(now, cx); + let session = (now + Duration::hours(3)).to_rfc3339(); + let week = (start + Duration::days(7)).to_rfc3339(); + let payload = format!( + r#"{{"five_hour":{{"utilization":12,"resets_at":"{session}"}},"seven_day":{{"utilization":8,"resets_at":"{week}"}}}}"# + ); + let parsed = codexbar_providers::claude::parse_usage(&payload, None, now).unwrap(); + let account = AccountSnapshot::new( + AccountId::new("claude"), + Provider::Claude, + parsed.metrics().to_vec(), + now, + ) + .with_plan("Max 20x") + .with_trend(vec![0.02, 0.05, 0.08]); + self.states.clear(); + self.placeholders.clear(); + self.set_accounts(vec![account], cx); + crate::prefs_hub::PrefsHub::update_focus_choice(cx, "claude", |choice| { + choice.rings = vec!["weekly".into()]; + }); + self.focus_account( + &crate::handoff::FocusRequest { + account: "claude".into(), + metric: Some("weekly".into()), + }, + cx, + ); + self.view = DashboardView::Usage; + cx.notify(); + } + /// Synthetic CLI payload through the real parser, for age-boundary rendering without live accounts. #[cfg(any(test, feature = "capture"))] pub(crate) fn capture_gemini_weekly(&mut self, mature: bool, cx: &mut Context) { @@ -1911,7 +1951,12 @@ impl Dashboard { gpui_kit::component::h_flex() .gap_1() .child(div().text_xs().child("Fleet")) - .child(crate::sus::badge(fleet.score, "fleet-sus", "Fleet SUS", cx)) + .child(crate::sus::badge( + fleet.score.map(codexbar_core::sus::Reading::Firm), + "fleet-sus", + "Fleet SUS", + cx, + )) .child(div().text_xs().text_color(cx.theme().muted_foreground).child( if fleet.subscriptions == 0 { "no eligible subscriptions".to_owned() diff --git a/crates/codexbar-app/src/self_capture.rs b/crates/codexbar-app/src/self_capture.rs index 5444c5c3..d06f9b9f 100644 --- a/crates/codexbar-app/src/self_capture.rs +++ b/crates/codexbar-app/src/self_capture.rs @@ -18,6 +18,7 @@ use crate::dashboard::{Dashboard, DashboardView}; use crate::theme::Appearance; mod antigravity; +mod claude_weekly; mod gemini_status; mod gemini_weekly; mod muse; @@ -39,6 +40,7 @@ pub fn start(cx: &mut App, handle: AnyWindowHandle, dashboard: Entity || std::env::var_os("CODEXBAR_CAPTURE_MUSE").is_some() || std::env::var_os("CODEXBAR_CAPTURE_GEMINI_WEEKLY").is_some() || std::env::var_os("CODEXBAR_CAPTURE_GEMINI_STATUS").is_some() + || std::env::var_os("CODEXBAR_CAPTURE_CLAUDE_WEEKLY").is_some() { let hub = crate::settings_hub::SettingsHub::global(cx); assert!( @@ -206,6 +208,9 @@ async fn run( if std::env::var_os("CODEXBAR_CAPTURE_GEMINI_WEEKLY").is_some() { saved.extend(gemini_weekly::run(cx, handle, dashboard, dir).await); } + if std::env::var_os("CODEXBAR_CAPTURE_CLAUDE_WEEKLY").is_some() { + saved.extend(claude_weekly::run(cx, handle, dashboard, dir).await); + } if std::env::var_os("CODEXBAR_CAPTURE_GEMINI_STATUS").is_some() { saved.extend(gemini_status::run(cx, handle, dashboard, dir).await); } diff --git a/crates/codexbar-app/src/self_capture/claude_weekly.rs b/crates/codexbar-app/src/self_capture/claude_weekly.rs new file mode 100644 index 00000000..dbe6fe15 --- /dev/null +++ b/crates/codexbar-app/src/self_capture/claude_weekly.rs @@ -0,0 +1,65 @@ +//! A synthetic Claude week through the production parser and views: n/a, early from 1 hour, firm from 12 hours. +use super::{save, settle}; +use crate::{dashboard::Dashboard, theme::Appearance}; +use chrono::Duration; +use gpui_kit::test::TestWindowExt as _; +use gpui_kit::{AnyWindowHandle, AppContext as _, AsyncApp, Entity}; +use std::path::Path; + +/// Each age into an 8%-used week, its screenshot name, its SUS label, and whether its bar forecasts. +pub(crate) fn states() -> [(Duration, &'static str, &'static str, bool); 6] { + [ + (Duration::minutes(59), "59m", "Weekly: SUS n/a, unavailable", false), + (Duration::hours(1), "1h", "Weekly: SUS 7 early, early estimate", false), + (Duration::hours(6), "6h", "Weekly: SUS 45 early, early estimate", false), + ( + Duration::hours(12) - Duration::minutes(1), + "11h59m", + "Weekly: SUS 89 early, early estimate", + false, + ), + (Duration::hours(12), "12h", "Weekly: SUS 89, yellow", true), + ( + Duration::hours(15) + Duration::minutes(55), + "15h55m", + "Weekly: SUS 84, yellow", + true, + ), + ] +} + +pub(super) async fn run( + cx: &mut AsyncApp, + handle: AnyWindowHandle, + dashboard: &Entity, + dir: &Path, +) -> Vec { + let wide = std::env::var_os("CODEXBAR_CAPTURE_WIDE").is_some(); + let layout = if wide { "wide" } else { "compact" }; + let mut saved = Vec::new(); + for (appearance, theme) in [(Appearance::Light, "light"), (Appearance::Dark, "dark")] { + cx.update(|cx| crate::prefs_hub::PrefsHub::set_appearance(cx, appearance)); + for (age, name, sus, forecasts) in states() { + cx.update(|cx| dashboard.update(cx, |d, cx| d.capture_claude_weekly(age, cx))); + settle(cx).await; + cx.update(|cx| { + cx.update_window(handle, |_, window, cx| { + window.render_frame(cx); + let id = if wide { + "window-weekly-claude-bar-sus" + } else { + "focus-metric-1-sus" + }; + assert_eq!(window.find(id).label(), Some(sus), "{name}"); + let ring = window.find("focus-share").label().unwrap().to_owned(); + assert_eq!(ring.contains("at reset"), forecasts, "{name}: {ring}"); + }) + }) + .expect("the Claude fixture must reach the actual table and weekly card"); + let name = format!("claude-weekly-{name}-{layout}-{theme}"); + assert!(save(cx, handle, dir, &name)); + saved.push(name); + } + } + saved +} diff --git a/crates/codexbar-app/src/sus.rs b/crates/codexbar-app/src/sus.rs index 7a37fa2d..602b5aa7 100644 --- a/crates/codexbar-app/src/sus.rs +++ b/crates/codexbar-app/src/sus.rs @@ -3,37 +3,48 @@ use crate::{account_table::AccountState, settings_hub::SettingsHub}; use chrono::{DateTime, Utc}; use codexbar_core::{ AccountSnapshot, Metric, - sus::{self, Band, Score}, + sus::{self, Band, Reading}, }; use gpui_kit::component::{ActiveTheme as _, StyledExt as _, tooltip::Tooltip}; use gpui_kit::{ AnyElement, App, ElementId, InteractiveElement as _, IntoElement as _, ParentElement as _, - StatefulInteractiveElement as _, Styled as _, TestSupportExt as _, div, + StatefulInteractiveElement as _, Styled as _, TestSupportExt as _, div, prelude::FluentBuilder as _, }; use std::collections::{BTreeMap, HashMap}; -pub fn badge(score: Option, id: &str, label: &str, cx: &App) -> AnyElement { - let text = score - .map(|s| format!("SUS {}", s.value())) - .unwrap_or_else(|| "SUS n/a".into()); - let (color, band) = match score.map(Score::band) { - Some(Band::Green) => (cx.theme().success, "green"), - Some(Band::Yellow) => ( - gpui_kit::rgb(if cx.theme().is_dark() { 0xe3b341 } else { 0x9a6700 }).into(), - "yellow", - ), - Some(Band::Red) => (cx.theme().danger, "red"), +pub fn badge(reading: Option, id: &str, label: &str, cx: &App) -> AnyElement { + let text = reading.map_or_else(|| "SUS n/a".into(), Reading::text); + // An early score is provisional: it takes no band color, and the label says so instead. + let (color, band) = match reading { + Some(Reading::Firm(score)) => match score.band() { + Band::Green => (cx.theme().success, "green"), + Band::Yellow => ( + gpui_kit::rgb(if cx.theme().is_dark() { 0xe3b341 } else { 0x9a6700 }).into(), + "yellow", + ), + Band::Red => (cx.theme().danger, "red"), + }, + Some(Reading::Early(_)) => (cx.theme().muted_foreground.opacity(0.75), "early estimate"), None => (cx.theme().muted_foreground, "unavailable"), }; let spoken = format!("{label}: {text}, {band}"); - let tooltip:gpui_kit::SharedString="Weekly Sub Utilization Score, 0–100. 100 means using the full allowance at reset. Green ≥90, yellow ≥75, red <75. n/a means too little data, a window younger than 12 hours, or no quota forecast.".into(); + let tooltip: gpui_kit::SharedString = if reading.is_some_and(Reading::is_early) { + "Early Weekly Sub Utilization Score: an estimate from usage in this week's first hours. It firms up 12 hours \ + into the window. Until then it has no color and never raises risk or an alert." + .into() + } else { + "Weekly Sub Utilization Score, 0–100. 100 means using the full allowance at reset. Green ≥90, yellow ≥75, red \ + <75. Early values from 1 hour are gray and marked early until 12 hours. n/a means too little data, a window \ + younger than 1 hour, or no quota forecast." + .into() + }; div() .id(ElementId::Name(id.to_owned().into())) .test_support() .aria_label(spoken) .flex_none() .text_xs() - .font_semibold() + .when(!reading.is_some_and(Reading::is_early), |this| this.font_semibold()) .text_color(color) .child(text) .tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx)) @@ -53,7 +64,7 @@ pub fn metric( } fn metric_badge(metric: &Metric, now: DateTime, id: &str, cx: &App) -> Option { if sus::is_weekly(metric) { - Some(badge(sus::weekly(metric, now), id, metric.label(), cx)) + Some(badge(sus::reading(metric, now), id, metric.label(), cx)) } else if matches!(metric,Metric::Activity{label,..} if label=="Turns/7d") { Some(badge(None, id, "Tracked activity has no quota", cx)) } else { diff --git a/crates/codexbar-app/src/ui_tests.rs b/crates/codexbar-app/src/ui_tests.rs index cdfc62da..6d0a51ba 100644 --- a/crates/codexbar-app/src/ui_tests.rs +++ b/crates/codexbar-app/src/ui_tests.rs @@ -8346,7 +8346,8 @@ fn gemini_models_weekly_is_the_row_pool_and_matures_at_twelve_hours(cx: &mut Tes Some(if mature { "Gemini Models weekly: SUS 42, red" } else { - "Gemini Models weekly: SUS n/a, unavailable" + // Early before 12 hours: shown, but without a band, a forecast or a projected marker. + "Gemini Models weekly: SUS 42 early, early estimate" }) ); assert_eq!( @@ -8379,6 +8380,56 @@ fn gemini_models_weekly_is_the_row_pool_and_matures_at_twelve_hours(cx: &mut Tes } } +/// A Claude week through the real parser: n/a before 1 hour, an uncolored early SUS until 12 hours, then the firm, +/// banded score with its forecast, in both layouts. +#[gpui_kit::test] +fn claude_weekly_sus_is_early_from_one_hour_and_firm_from_twelve(cx: &mut TestAppContext) { + use chrono::Duration as Age; + let settings = TempSettings::new("claude-weekly-early", "{}"); + let (handle, dashboard) = open_dashboard(cx, &settings); + for wide in [false, true] { + gpui_kit::VisualTestContext::from_window(handle, cx) + .simulate_resize(size(px(if wide { 2600. } else { 1440. }), px(1000.))); + for (age, sus, forecast) in [ + (Age::minutes(59), "Weekly: SUS n/a, unavailable", None), + (Age::hours(1), "Weekly: SUS 7 early, early estimate", None), + ( + Age::hours(12) - Age::minutes(1), + "Weekly: SUS 89 early, early estimate", + None, + ), + ( + Age::hours(12), + "Weekly: SUS 89, yellow", + Some("Weekly: 112% at reset · runs out in 5d 18h"), + ), + ( + Age::hours(15) + Age::minutes(55), + "Weekly: SUS 84, yellow", + Some("Weekly: 84% at reset"), + ), + ] { + cx.update(|cx| dashboard.update(cx, |d, cx| d.capture_claude_weekly(age, cx))); + cx.run_until_parked(); + cx.update_window(handle, |_, window, cx| window.render_frame(cx)) + .unwrap(); + let (badge, at_reset) = if wide { + ("window-weekly-claude-bar-sus", "window-weekly-claude-at-reset") + } else { + ("focus-metric-1-sus", "focus-metric-1-at-reset") + }; + assert_eq!(label(cx, handle, badge).as_deref(), Some(sus), "{age} wide={wide}"); + if wide { + assert_eq!( + label(cx, handle, at_reset).as_deref(), + Some(forecast.unwrap_or("Weekly at reset: no forecast")), + "{age}" + ); + } + } + } +} + /// Explicit, read-only Windows qualification. No GPUI app/window is created by this ordinary Rust test. #[test] #[ignore = "requires an explicitly authorized local snapshot directory"] diff --git a/crates/codexbar-app/src/widget_builder.rs b/crates/codexbar-app/src/widget_builder.rs index 018a4e7e..1abe5598 100644 --- a/crates/codexbar-app/src/widget_builder.rs +++ b/crates/codexbar-app/src/widget_builder.rs @@ -227,7 +227,13 @@ fn preview_tile(ix: usize, view: &TileView, compact: bool, cx: &App) -> impl Int ) .when_some(view.sus.as_ref(), |this, _| { this.child(crate::sus::badge( - view.sus_score.and_then(codexbar_core::sus::Score::from_value), + { + use codexbar_core::sus::{Reading, Score}; + view.sus_score + .and_then(Score::from_value) + .map(Reading::Firm) + .or_else(|| view.sus_early.and_then(Score::from_value).map(Reading::Early)) + }, &format!("widget-{ix}-sus"), "Widget weekly score", cx, diff --git a/crates/codexbar-app/src/widget_feed.rs b/crates/codexbar-app/src/widget_feed.rs index 1d44a59c..ecf66ba2 100644 --- a/crates/codexbar-app/src/widget_feed.rs +++ b/crates/codexbar-app/src/widget_feed.rs @@ -110,6 +110,9 @@ fn metric(account: &AccountSnapshot, metric: &Metric, now: DateTime) -> Wid sus: codexbar_core::sus::weekly(metric, now) .filter(|_| account.sus_eligible(metric, now)) .map(|s| s.value()), + sus_early: codexbar_core::sus::early(metric, now) + .filter(|_| account.sus_eligible(metric, now)) + .map(|s| s.value()), sus_eligible: codexbar_core::sus::text(metric, now) .filter(|_| account.sus_eligible(metric, now)) .is_some(), @@ -197,6 +200,29 @@ mod tests { assert_eq!(x.metrics[0].sus, None); } + #[test] + fn an_early_week_carries_its_early_score_apart_from_the_firm_one() { + let early = AccountSnapshot::new( + AccountId::new("claude-early"), + Provider::Claude, + vec![Metric::Window { + label: "Weekly".into(), + used: 0.08, + resets_at: now() + chrono::Duration::hours(162), + pace: None, + length: Some(chrono::Duration::days(7)), + }], + now(), + ); + let snapshot = build(&[early], &States::new(), &Layout::default(), now()); + let metric = &snapshot.accounts[0].metrics[0]; + assert_eq!( + (metric.sus, metric.sus_early, metric.sus_eligible), + (None, Some(45), true) + ); + assert_eq!(metric.projected_percent, None); + } + #[test] fn a_paced_window_carries_its_projection_and_an_unpaced_one_none() { // 1%/h with 48h left on top of 42%: 90% at the reset (#197), near the limit (#234). diff --git a/crates/codexbar-app/src/widgets/cards.rs b/crates/codexbar-app/src/widgets/cards.rs index 252139ce..58cd3177 100644 --- a/crates/codexbar-app/src/widgets/cards.rs +++ b/crates/codexbar-app/src/widgets/cards.rs @@ -283,7 +283,10 @@ pub struct TileView { /// Freshness or what's wrong: "Last known, 5m ago", "Unavailable", "Resets in 2d". pub note: Option, pub sus: Option, + /// The firm score, the only one that colors the tile. pub sus_score: Option, + /// The early score while no firm one exists; shown marked early and subtle. + pub sus_early: Option, } impl TileView { @@ -300,6 +303,7 @@ impl TileView { note: Some(note.to_owned()), sus: None, sus_score: None, + sus_early: None, } } @@ -358,6 +362,9 @@ impl TileView { freshness, ), }; + // A firm score wins; an early one shows only without it. + let sus_score = found.sus.filter(|s| *s <= 100); + let sus_early = found.sus_early.filter(|s| *s <= 100 && sus_score.is_none()); Self { account: Some(account.id.clone()), metric: Some(found.key.clone()), @@ -368,14 +375,13 @@ impl TileView { percent, status, note, - sus: found.sus_eligible.then(|| { - found - .sus - .filter(|s| *s <= 100) - .map(|s| format!("SUS {s}")) - .unwrap_or_else(|| "SUS n/a".into()) + sus: found.sus_eligible.then(|| match (sus_score, sus_early) { + (Some(s), _) => format!("SUS {s}"), + (None, Some(s)) => format!("SUS {s} early"), + (None, None) => "SUS n/a".into(), }), - sus_score: found.sus.filter(|s| *s <= 100), + sus_score, + sus_early, } } } @@ -478,6 +484,9 @@ fn tile(view: &TileView, compact: bool) -> Value { Some(_) => "Attention", None => "Default", }); + if view.sus_score.is_none() && view.sus_early.is_some() { + score["isSubtle"] = json!(true); + } items.push(score); } if let Some(big) = &view.big { @@ -695,6 +704,7 @@ mod tests { projected_percent: None, outlook: None, sus: None, + sus_early: None, sus_eligible: false, }], } @@ -896,6 +906,7 @@ mod tests { projected_percent: None, outlook: None, sus: None, + sus_early: None, sus_eligible: false, }); snapshot.accounts[0].metrics[0].resets_at = Some(now() + chrono::Duration::hours(50)); @@ -923,6 +934,32 @@ mod tests { tile_views(snapshot.as_ref().unwrap(), &config, size, now()).unwrap() } + #[test] + fn an_early_score_is_marked_and_subtle_and_a_firm_one_wins() { + let mut snapshot = snapshot().unwrap(); + let metric = &mut snapshot.accounts[0].metrics[0]; + metric.sus_eligible = true; + metric.sus_early = Some(45); + let view = TileView::of(&snapshot.accounts[0], Some("weekly"), TileMode::Automatic, now()); + assert_eq!(view.sus.as_deref(), Some("SUS 45 early")); + assert_eq!((view.sus_score, view.sus_early), (None, Some(45))); + let score = &tile(&view, false)["items"][1]; + assert_eq!(score["text"], "SUS 45 early"); + assert_eq!(score["color"], "Default", "an early score never takes a band color"); + assert_eq!(score["isSubtle"], true); + snapshot.accounts[0].metrics[0].sus = Some(89); + let view = TileView::of(&snapshot.accounts[0], Some("weekly"), TileMode::Automatic, now()); + assert_eq!(view.sus.as_deref(), Some("SUS 89")); + assert_eq!((view.sus_score, view.sus_early), (Some(89), None)); + let score = &tile(&view, false)["items"][1]; + assert_eq!(score["color"], "Warning"); + assert!(score.get("isSubtle").is_none()); + snapshot.accounts[0].metrics[0].sus = None; + snapshot.accounts[0].metrics[0].sus_early = Some(101); + let view = TileView::of(&snapshot.accounts[0], Some("weekly"), TileMode::Automatic, now()); + assert_eq!(view.sus.as_deref(), Some("SUS n/a")); + } + #[test] fn a_projected_metric_says_so_on_its_line() { // Adaptive Cards can't fade a bar, so the projection is a figure on the line (#197). diff --git a/crates/codexbar-core/src/sus.rs b/crates/codexbar-core/src/sus.rs index 8f8cd78f..41723a91 100644 --- a/crates/codexbar-core/src/sus.rs +++ b/crates/codexbar-core/src/sus.rs @@ -1,5 +1,5 @@ //! Weekly Sub Utilization Score. The projection and exhaustion time are the metric's existing forecast. -use crate::{AccountSnapshot, Metric, Provider}; +use crate::{AccountSnapshot, Metric, Pace, Provider}; use chrono::{DateTime, Duration, Utc}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -50,6 +50,34 @@ pub fn calculate(projected_percent: f64, out_hours: Option, reset_hours: f6 }; Some(Score::rounded(value)) } +/// A week shows an early, provisional SUS from this age. Early values are display-only: no forecast, risk or color. +pub const EARLY_SUS: Duration = Duration::hours(1); +/// Weekly forecasts and SUS firm up together at 12 hours, the fresh-window guard of usage.py and the Pi dashboard. +pub const WEEKLY_MATURITY: Duration = Duration::hours(12); + +/// A weekly score as shown: early from [`EARLY_SUS`], firm from [`WEEKLY_MATURITY`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Reading { + Early(Score), + Firm(Score), +} +impl Reading { + pub fn is_early(self) -> bool { + matches!(self, Self::Early(_)) + } + /// "SUS 84", or "SUS 84 early". + pub fn text(self) -> String { + match self { + Self::Early(score) => format!("SUS {} early", score.value()), + Self::Firm(score) => format!("SUS {}", score.value()), + } + } +} + +/// A provider week: seven days, or a calendar week that crosses an offset change. +pub fn is_week_length(length: Duration) -> bool { + (Duration::days(6)..=Duration::days(8)).contains(&length) +} pub fn is_weekly(metric: &Metric) -> bool { let declared_weekly = matches!(metric, Metric::Window { .. } | Metric::Quota { .. }) && metric @@ -61,20 +89,27 @@ pub fn is_weekly(metric: &Metric) -> bool { Some(length) if length == Duration::days(7) => true, // Provider calendar weeks can cross offset changes. Keep their actual length for age and lockout math; // the weekly label is required outside exactly seven days, and short/monthly periods stay excluded. - Some(length) => declared_weekly && (Duration::days(6)..=Duration::days(8)).contains(&length), + Some(length) => declared_weekly && is_week_length(length), None => declared_weekly, } } -pub fn weekly(metric: &Metric, now: DateTime) -> Option { +fn hours(d: Duration) -> f64 { + d.num_milliseconds() as f64 / 3_600_000. +} +/// A weekly metric's length, reset and age, or `None` when it isn't weekly or they are unknown. +fn week(metric: &Metric, now: DateTime) -> Option<(Duration, DateTime, Duration)> { if !is_weekly(metric) { return None; } let length = metric.window_length()?; let reset = metric.resets_at()?; - if now.signed_duration_since(reset.checked_sub_signed(length)?) < Duration::hours(12) { - return None; - } - let hours = |d: Duration| d.num_milliseconds() as f64 / 3_600_000.; + Some(( + length, + reset, + now.signed_duration_since(reset.checked_sub_signed(length)?), + )) +} +fn score(metric: &Metric, now: DateTime, length: Duration, reset: DateTime) -> Option { calculate( metric.projected_at_reset(now)? * 100., metric.time_to_limit().map(hours), @@ -82,14 +117,38 @@ pub fn weekly(metric: &Metric, now: DateTime) -> Option { hours(length), ) } +/// The firm score: the metric's own forecast, from [`WEEKLY_MATURITY`]. Only firm scores may color or rank. +pub fn weekly(metric: &Metric, now: DateTime) -> Option { + let (length, reset, age) = week(metric, now)?; + if age < WEEKLY_MATURITY { + return None; + } + score(metric, now, length, reset) +} +/// The early score from [`EARLY_SUS`] until [`WEEKLY_MATURITY`]: usage so far over the time elapsed, projected to +/// the reset. The pace stays local to the score, so the metric still forecasts nothing and raises no risk. +pub fn early(metric: &Metric, now: DateTime) -> Option { + let (length, reset, age) = week(metric, now)?; + if !(EARLY_SUS..WEEKLY_MATURITY).contains(&age) { + return None; + } + let mut provisional = metric.clone(); + // Only windows and quotas carry the length a week needs. + if let Metric::Window { pace, .. } | Metric::Quota { pace, .. } = &mut provisional { + *pace = Some(Pace::per_hour(metric.used_fraction()? / hours(age))); + } + score(&provisional, now, length, reset) +} +/// The score to show: firm once mature, early before that, `None` for `SUS n/a`. +pub fn reading(metric: &Metric, now: DateTime) -> Option { + weekly(metric, now) + .map(Reading::Firm) + .or_else(|| early(metric, now).map(Reading::Early)) +} pub fn text(metric: &Metric, now: DateTime) -> Option { if is_weekly(metric) { - Some( - weekly(metric, now) - .map(|s| format!("SUS {}", s.value())) - .unwrap_or_else(|| "SUS n/a".into()), - ) + Some(reading(metric, now).map_or_else(|| "SUS n/a".into(), Reading::text)) } else if matches!(metric,Metric::Activity{label,..} if label=="Turns/7d") { Some("SUS n/a".into()) } else { @@ -306,6 +365,65 @@ mod tests { assert_eq!(weekly(&m, now()), None); assert_eq!(calculate(f64::NAN, None, 5., 168.), None); } + /// `used` of a seven-day week `age` old, with the provider pace that only exists from the 12-hour maturity. + fn young(used: f64, age: Duration) -> Metric { + Metric::Window { + label: "Weekly".into(), + used, + resets_at: now() + Duration::days(7) - age, + pace: (age >= WEEKLY_MATURITY).then(|| Pace::per_hour(used / (age.num_seconds() as f64 / 3600.))), + length: Some(Duration::days(7)), + } + } + #[test] + fn early_from_one_hour_and_firm_from_twelve() { + let second = Duration::seconds(1); + for (age, expected) in [ + (EARLY_SUS - second, None), + (EARLY_SUS, Some(Reading::Early(Score(7)))), + (Duration::hours(6), Some(Reading::Early(Score(45)))), + (WEEKLY_MATURITY - second, Some(Reading::Early(Score(89)))), + (WEEKLY_MATURITY, Some(Reading::Firm(Score(89)))), + ] { + let metric = young(0.08, age); + assert_eq!(reading(&metric, now()), expected, "{age}"); + assert_eq!(weekly(&metric, now()).is_some(), age >= WEEKLY_MATURITY, "{age}"); + let shown = expected.map_or_else(|| "SUS n/a".into(), Reading::text); + assert_eq!(text(&metric, now()), Some(shown), "{age}"); + } + assert_eq!(Reading::Early(Score(55)).text(), "SUS 55 early"); + assert!(Reading::Early(Score(55)).is_early() && !Reading::Firm(Score(55)).is_early()); + // Five-hour windows still have no SUS at any age. + let mut session = young(0.3, Duration::hours(2)); + if let Metric::Window { length, resets_at, .. } = &mut session { + *length = Some(Duration::hours(5)); + *resets_at = now() + Duration::hours(3); + } + assert_eq!(reading(&session, now()), None); + assert_eq!(text(&session, now()), None); + } + #[test] + fn an_early_score_never_forecasts_raises_risk_alerts_or_fleet() { + // 30% two hours into a week projects a lockout: a red-range early score, and nothing else may act on it. + let at = now(); + let metric = young(0.3, Duration::hours(2)); + assert_eq!(reading(&metric, at), Some(Reading::Early(Score(4)))); + assert_eq!(metric.projected_at_reset(at), None); + assert_eq!(metric.time_to_limit(), None); + let account = AccountSnapshot::new(AccountId::new("early"), Provider::Claude, vec![metric], at); + assert_eq!(account.assess(at).severity(), crate::Severity::Normal); + assert_eq!(subscription_score(&account, at), None, "fleet SUS stays firm-only"); + let alerts = crate::alerts::evaluate( + &crate::alerts::AlertSettings { + enabled: true, + ..Default::default() + }, + &Default::default(), + &[account], + at, + ); + assert!(alerts.notify.is_empty()); + } #[test] fn score_inputs_reject_each_invalid_dimension_and_accept_zero_usage() { assert_eq!(calculate(0., None, 5., 168.).unwrap().value(), 0); diff --git a/crates/codexbar-providers/src/claude.rs b/crates/codexbar-providers/src/claude.rs index f05b4b25..bb4b438a 100644 --- a/crates/codexbar-providers/src/claude.rs +++ b/crates/codexbar-providers/src/claude.rs @@ -1288,6 +1288,47 @@ mod tests { ); } + #[test] + fn weekly_sus_is_early_from_one_hour_and_firm_with_its_forecast_from_twelve() { + // A week that reset Mon 05:00 ET at 8% used was SUS n/a at 20:55 ET: pace waited for 10% of it, 16h 48m. + let start: DateTime = "2026-10-26T09:00:00Z".parse().unwrap(); + let body = r#"{"seven_day":{"utilization":8,"resets_at":"2026-11-02T09:00:00Z"}}"#; + for (age, sus, firm) in [ + (Duration::minutes(59), "SUS n/a", false), + (Duration::hours(1), "SUS 7 early", false), + (Duration::hours(12) - Duration::minutes(1), "SUS 89 early", false), + (Duration::hours(12), "SUS 89", true), + (Duration::hours(15) + Duration::minutes(55), "SUS 84", true), + ] { + let at = start + age; + let account = parse_usage(body, None, at).unwrap(); + let weekly = &account.metrics()[0]; + assert_eq!(weekly.projected_at_reset(at).is_some(), firm, "{age}"); + assert_eq!(codexbar_core::sus::text(weekly, at).as_deref(), Some(sus), "{age}"); + assert_eq!( + codexbar_core::sus::subscription_score(&account, at).map(|s| format!("SUS {}", s.value())), + firm.then(|| sus.to_owned()), + "{age}: only a firm score reaches the subscription" + ); + } + // The session still projects only after 10% of its five hours. + let at = start + Duration::hours(12); + for (elapsed, projects) in [(Duration::minutes(29), false), (Duration::minutes(30), true)] { + let reset = (at + Duration::hours(5) - elapsed).to_rfc3339(); + let account = parse_usage( + &format!(r#"{{"five_hour":{{"utilization":10,"resets_at":"{reset}"}}}}"#), + None, + at, + ) + .unwrap(); + assert_eq!( + account.metrics()[0].projected_at_reset(at).is_some(), + projects, + "{elapsed}" + ); + } + } + #[test] fn flat_fields_fill_in_without_promoting_codenames() { let account = with(&format!( diff --git a/crates/codexbar-providers/src/codex.rs b/crates/codexbar-providers/src/codex.rs index 4655599a..375bd2c2 100644 --- a/crates/codexbar-providers/src/codex.rs +++ b/crates/codexbar-providers/src/codex.rs @@ -688,6 +688,40 @@ mod tests { assert_eq!(account.assess(now()).severity(), Severity::Normal); } + #[test] + fn weekly_sus_is_early_from_one_hour_and_firm_with_its_forecast_from_twelve() { + let window = |key: &str, used: u32, length: i64, elapsed: Duration| { + let reset = (now() + Duration::seconds(length) - elapsed).timestamp(); + let payload = format!( + r#"{{"rate_limit":{{"{key}":{{"used_percent":{used},"reset_at":{reset},"limit_window_seconds":{length}}}}}}}"# + ); + parse_usage(&payload, now()).unwrap() + }; + for (elapsed, sus, firm) in [ + (Duration::minutes(59), "SUS n/a", false), + (Duration::hours(1), "SUS 7 early", false), + (Duration::hours(12) - Duration::minutes(1), "SUS 89 early", false), + (Duration::hours(12), "SUS 89", true), + ] { + let account = window("secondary_window", 8, 604_800, elapsed); + let weekly = account.primary().unwrap(); + assert_eq!(weekly.projected_at_reset(now()).is_some(), firm, "{elapsed}"); + assert_eq!( + codexbar_core::sus::text(weekly, now()).as_deref(), + Some(sus), + "{elapsed}" + ); + } + for (elapsed, projects) in [(Duration::minutes(29), false), (Duration::minutes(30), true)] { + let account = window("primary_window", 10, 18_000, elapsed); + assert_eq!( + account.primary().unwrap().projected_at_reset(now()).is_some(), + projects, + "{elapsed}" + ); + } + } + #[test] fn a_codex_plan_transition_refreshes_without_changing_identity_or_the_account_name() { struct Plans(Mutex>); diff --git a/crates/codexbar-providers/src/gemini_coding.rs b/crates/codexbar-providers/src/gemini_coding.rs index acbc3a19..2096a0e4 100644 --- a/crates/codexbar-providers/src/gemini_coding.rs +++ b/crates/codexbar-providers/src/gemini_coding.rs @@ -13,7 +13,7 @@ use chrono::{DateTime, Duration, Utc}; use codexbar_core::Metric; use serde_json::Value; -use crate::pace::{elapsed_pace, elapsed_pace_after}; +use crate::pace::elapsed_pace; use crate::{CommandError, CommandRunner}; /// The CLI's usage command can take a moment to start; it never runs a model turn. @@ -139,7 +139,7 @@ pub fn parse(stdout: &str, now: DateTime) -> Result) -> Result, now: DateTime) -> Option { - let start = resets_at - Duration::hours(hours); - if hours == 7 * 24 { - elapsed_pace_after(used, start, resets_at, now, Duration::hours(12)) - } else { - elapsed_pace(used, start, resets_at, now) - } -} - /// Runs the CLI and reads its coding quotas. /// The first Antigravity CLI whose non-interactive `/usage` answers without starting an agent turn (its changelog, /// 1.1.11). An older one could send `/usage` to the model as a prompt, using quota and starting a conversation. diff --git a/crates/codexbar-providers/src/muse.rs b/crates/codexbar-providers/src/muse.rs index 902e49f7..cd85f8f9 100644 --- a/crates/codexbar-providers/src/muse.rs +++ b/crates/codexbar-providers/src/muse.rs @@ -7,7 +7,10 @@ use std::{ }; use chrono::{DateTime, Duration, Utc}; -use codexbar_core::{AccountId, AccountSnapshot, Metric, Pace, Provider}; +use codexbar_core::{ + AccountId, AccountSnapshot, Metric, Pace, Provider, + sus::{WEEKLY_MATURITY, is_week_length}, +}; use serde_json::Value; use crate::{CommandError, CommandOptions, CommandRunner, ProviderError, UsageProvider}; @@ -177,8 +180,14 @@ pub fn parse(text: &str, fetched: DateTime, id: &str, label: &str) -> Resul }; let elapsed = length - (resets_at - observed); let used = percent / 100.0; - let pace = (elapsed >= Duration::minutes(2)) - .then(|| Pace::per_hour(used * 3600.0 / elapsed.num_milliseconds() as f64 * 1000.0)); + // The week forecasts from the shared weekly maturity; the five-hour window keeps its two minutes. + let maturity = if is_week_length(length) { + WEEKLY_MATURITY + } else { + Duration::minutes(2) + }; + let pace = + (elapsed >= maturity).then(|| Pace::per_hour(used * 3600.0 / elapsed.num_milliseconds() as f64 * 1000.0)); metrics.push(Metric::Window { label: label.into(), used, @@ -264,6 +273,25 @@ mod tests { assert_eq!(a.subscription_price(), None); } #[test] + fn the_week_forecasts_from_twelve_hours_and_the_window_from_two_minutes() { + let observed = now().timestamp_millis(); + for (elapsed, projects) in [ + (Duration::hours(12) - Duration::minutes(1), false), + (Duration::hours(12), true), + ] { + let reset = observed + (Duration::days(7) - elapsed).num_milliseconds(); + let payload = MEASURED.replace("1793577600000", &reset.to_string()); + let weekly = reading(&payload, now()).unwrap().metrics()[1].clone(); + assert_eq!(weekly.projected_at_reset(now()).is_some(), projects, "{elapsed}"); + } + for (elapsed, projects) in [(Duration::seconds(119), false), (Duration::minutes(2), true)] { + let reset = observed + (Duration::hours(5) - elapsed).num_milliseconds(); + let payload = MEASURED.replace("1793053104000", &reset.to_string()); + let window = reading(&payload, now()).unwrap().metrics()[0].clone(); + assert_eq!(window.projected_at_reset(now()).is_some(), projects, "{elapsed}"); + } + } + #[test] fn zero_exhausted_partial_and_bad_siblings_keep_truthful_values() { let zero = reading(include_str!("../fixtures/muse/zero.json"), now()).unwrap(); assert!(zero.metrics().iter().all(|m| m.used_fraction() == Some(0.0))); diff --git a/crates/codexbar-providers/src/pace.rs b/crates/codexbar-providers/src/pace.rs index 1f53e817..b52168ed 100644 --- a/crates/codexbar-providers/src/pace.rs +++ b/crates/codexbar-providers/src/pace.rs @@ -1,22 +1,28 @@ use chrono::{DateTime, Duration, Utc}; -use codexbar_core::Pace; +use codexbar_core::{ + Pace, + sus::{WEEKLY_MATURITY, is_week_length}, +}; /// Pace is unreliable early in a period: a few minutes of a weekly window extrapolate to a false lockout. -/// It is only projected once this much time, and this share of the period, has elapsed. +/// A week projects from the SUS maturity; any other period once this much time, and this share of it, has elapsed. const MIN_ELAPSED_MINUTES: i64 = 10; const MIN_ELAPSED_FRACTION: f64 = 0.1; /// Usage so far divided by time elapsed in the period `[start, resets_at)`, or `None` while the period is too young. pub fn elapsed_pace(used: f64, start: DateTime, resets_at: DateTime, now: DateTime) -> Option { let length = resets_at - start; - let minimum = Duration::minutes(MIN_ELAPSED_MINUTES).max(Duration::seconds( - (length.num_seconds() as f64 * MIN_ELAPSED_FRACTION) as i64, - )); + let minimum = if is_week_length(length) { + WEEKLY_MATURITY + } else { + Duration::minutes(MIN_ELAPSED_MINUTES).max(Duration::seconds( + (length.num_seconds() as f64 * MIN_ELAPSED_FRACTION) as i64, + )) + }; elapsed_pace_after(used, start, resets_at, now, minimum) } -/// Projects after an explicit maturity point. Antigravity weekly pools mature with SUS at 12 hours (#262). -pub fn elapsed_pace_after( +fn elapsed_pace_after( used: f64, start: DateTime, resets_at: DateTime, @@ -59,6 +65,23 @@ mod tests { assert_eq!(zero.unwrap().fraction_per_hour(), 0.); } + #[test] + fn weeks_mature_at_the_sus_boundary_and_other_periods_at_ten_percent() { + // Calendar weeks across an offset change are weeks too; a five-hour window and a month keep 10%. + for (length, maturity) in [ + (Duration::days(7), WEEKLY_MATURITY), + (Duration::hours(167), WEEKLY_MATURITY), + (Duration::hours(169), WEEKLY_MATURITY), + (Duration::hours(5), Duration::minutes(30)), + (Duration::days(30), Duration::hours(72)), + ] { + let start = now() - maturity; + let early = now() - Duration::seconds(1); + assert_eq!(elapsed_pace(0.1, start, start + length, early), None, "{length}"); + assert!(elapsed_pace(0.1, start, start + length, now()).is_some(), "{length}"); + } + } + #[test] fn elapsed_pace_young_period_returns_none() { let start = now() - Duration::minutes(20); diff --git a/crates/codexbar-store/src/widgets.rs b/crates/codexbar-store/src/widgets.rs index 03da691a..5e1816df 100644 --- a/crates/codexbar-store/src/widgets.rs +++ b/crates/codexbar-store/src/widgets.rs @@ -286,8 +286,13 @@ pub struct WidgetMetric { /// before the reset; none with nothing to project. #[serde(default, skip_serializing_if = "Option::is_none")] pub outlook: Option, + /// The firm score, from the week's 12-hour maturity; the only one a tile may color. #[serde(default, skip_serializing_if = "Option::is_none")] pub sus: Option, + /// The early, provisional score from 1 hour while `sus` is absent: shown marked early, never colored. A separate + /// field so an older provider, which knows only `sus`, can never color it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sus_early: Option, #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub sus_eligible: bool, } @@ -409,6 +414,7 @@ mod tests { projected_percent: Some(130.0), outlook: Some("2d 12h early".into()), sus: None, + sus_early: None, sus_eligible: false, }], }], @@ -456,6 +462,19 @@ mod tests { assert_eq!(text, expected); } + #[test] + fn an_early_score_has_its_own_field() { + let mut snapshot = sample(); + let metric = &mut snapshot.accounts[0].metrics[0]; + metric.sus_early = Some(45); + metric.sus_eligible = true; + let text = serde_json::to_value(&snapshot).unwrap(); + let written = &text["accounts"][0]["metrics"][0]; + assert_eq!(written["susEarly"], 45); + assert!(written.get("sus").is_none(), "an older reader sees no score to color"); + assert_eq!(serde_json::from_value::(text).unwrap(), snapshot); + } + #[test] fn unknown_fields_from_a_newer_minor_change_are_ignored() { let dir = TempDir::new("unknown"); diff --git a/docs/SUS.md b/docs/SUS.md index af2a93fa..47923bf5 100644 --- a/docs/SUS.md +++ b/docs/SUS.md @@ -12,6 +12,23 @@ The score uses the metric's existing projection and time-to-limit, so its inputs agree with the forecast beside the bar. A window must be at least 12 hours old, have a known weekly length/reset, and have a forecast. Otherwise it says `SUS n/a`. +Every weekly window forecasts from the same 12-hour maturity, the fresh-window +guard of usage.py and the Pi dashboard. That covers Claude, Codex, Grok/X, +OpenCode, Gemini Apps, Antigravity and Muse Code. Five-hour, monthly and other +periods keep their existing rule: 10% of the period, at least 10 minutes (Muse +Code's five-hour window: two minutes). Before this, every week except +Antigravity's waited for 10% of the week, 16h 48m. That left SUS `n/a` until then. + +From 1 hour until 12 hours a weekly window shows an **early** SUS, such as +`SUS 45 early`. It applies the same formula to usage so far divided by the time +elapsed. It is gray, has no band color, and its tooltip says it firms up at 12 +hours. The bar still shows `no forecast`. An early value never sets a forecast, +status, risk badge, ordering, alert or fleet SUS. Widget feeds carry it as +`susEarly`, apart from the firm `sus`, so an older widget reader can't color it. +At 12 hours the firm score and its band take over. Before 1 hour it says +`SUS n/a`. Both thresholds are shared constants in `codexbar_core::sus`: +`EARLY_SUS` and `WEEKLY_MATURITY`. + - Projected usage at reset at or below 100%: SUS is the projected percentage. - Above 100%: `lockout = max(hours_to_reset - hours_to_limit, 0)` and `SUS = clamp(100 - 100 * lockout / window_hours, 0, 100)`. @@ -34,7 +51,7 @@ third-party models and Gemini Apps remain informational and never supply its row, subscription/fleet SUS, risk, severity ordering or alerts. Missing Models weekly stays `n/a`; another pool never replaces it. Its existing alert keys clear on a successful partial refresh. Other providers keep their existing policies. -Exclude `n/a` pools and unavailable/restored/failed accounts. Weight each scored +Exclude `n/a` and early pools and unavailable/restored/failed accounts. Weight each scored subscription once by its monthly USD price. Separate paid accounts at the same vendor remain separate subscriptions; multiple pools within one account never multiply its price. If any scored subscription's price is unknown, all scored @@ -137,3 +154,6 @@ binary with `--demo`, `CODEXBAR_CAPTURE_SUS=1`, a fresh `CODEXBAR_CAPTURE_DIR`, The harness asserts rendered fixture values before capturing light/dark states, including dated price changes, unknown prices, young Argon, and verified X quota versus activity. These synthetic UI fixtures never read live providers. +`CODEXBAR_CAPTURE_CLAUDE_WEEKLY=1` instead renders an 8%-used Claude week +through the production parser at 59 minutes (`n/a`), 1 hour, 6 hours and +11h 59m (early), 12 hours and 15h 55m (firm). It asserts each state's label. From c505e201efa11c292a5d7021c89bbf937d37e38a Mon Sep 17 00:00:00 2001 From: HemSoft Date: Mon, 26 Oct 2026 21:52:37 -0400 Subject: [PATCH 2/4] refactor: keep existing functions' complexity for the metrics gate The strict rust-metrics gate rejects any CRAP rise in an existing function. Move the new week/early decisions out of elapsed_pace, Muse parse, the SUS badge and the widget tile into small helpers (maturity, tone, tooltip, sus_line). Behavior is unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/codexbar-app/src/sus.rs | 42 ++++++++++++++---------- crates/codexbar-app/src/widgets/cards.rs | 29 ++++++++-------- crates/codexbar-providers/src/muse.rs | 19 ++++++----- crates/codexbar-providers/src/pace.rs | 11 ++++--- 4 files changed, 58 insertions(+), 43 deletions(-) diff --git a/crates/codexbar-app/src/sus.rs b/crates/codexbar-app/src/sus.rs index 602b5aa7..cba914ba 100644 --- a/crates/codexbar-app/src/sus.rs +++ b/crates/codexbar-app/src/sus.rs @@ -7,15 +7,31 @@ use codexbar_core::{ }; use gpui_kit::component::{ActiveTheme as _, StyledExt as _, tooltip::Tooltip}; use gpui_kit::{ - AnyElement, App, ElementId, InteractiveElement as _, IntoElement as _, ParentElement as _, + AnyElement, App, ElementId, Hsla, InteractiveElement as _, IntoElement as _, ParentElement as _, SharedString, StatefulInteractiveElement as _, Styled as _, TestSupportExt as _, div, prelude::FluentBuilder as _, }; use std::collections::{BTreeMap, HashMap}; pub fn badge(reading: Option, id: &str, label: &str, cx: &App) -> AnyElement { let text = reading.map_or_else(|| "SUS n/a".into(), Reading::text); - // An early score is provisional: it takes no band color, and the label says so instead. - let (color, band) = match reading { + let (color, band) = tone(reading, cx); + let early = reading.is_some_and(Reading::is_early); + let spoken = format!("{label}: {text}, {band}"); + div() + .id(ElementId::Name(id.to_owned().into())) + .test_support() + .aria_label(spoken) + .flex_none() + .text_xs() + .when(!early, |this| this.font_semibold()) + .text_color(color) + .child(text) + .tooltip(move |window, cx| Tooltip::new(tooltip(early)).build(window, cx)) + .into_any_element() +} +/// The color and spoken band. An early score is provisional: it takes no band color, and the label says so instead. +fn tone(reading: Option, cx: &App) -> (Hsla, &'static str) { + match reading { Some(Reading::Firm(score)) => match score.band() { Band::Green => (cx.theme().success, "green"), Band::Yellow => ( @@ -26,9 +42,10 @@ pub fn badge(reading: Option, id: &str, label: &str, cx: &App) -> AnyEl }, Some(Reading::Early(_)) => (cx.theme().muted_foreground.opacity(0.75), "early estimate"), None => (cx.theme().muted_foreground, "unavailable"), - }; - let spoken = format!("{label}: {text}, {band}"); - let tooltip: gpui_kit::SharedString = if reading.is_some_and(Reading::is_early) { + } +} +fn tooltip(early: bool) -> SharedString { + if early { "Early Weekly Sub Utilization Score: an estimate from usage in this week's first hours. It firms up 12 hours \ into the window. Until then it has no color and never raises risk or an alert." .into() @@ -37,18 +54,7 @@ pub fn badge(reading: Option, id: &str, label: &str, cx: &App) -> AnyEl <75. Early values from 1 hour are gray and marked early until 12 hours. n/a means too little data, a window \ younger than 1 hour, or no quota forecast." .into() - }; - div() - .id(ElementId::Name(id.to_owned().into())) - .test_support() - .aria_label(spoken) - .flex_none() - .text_xs() - .when(!reading.is_some_and(Reading::is_early), |this| this.font_semibold()) - .text_color(color) - .child(text) - .tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx)) - .into_any_element() + } } pub fn metric( account: &AccountSnapshot, diff --git a/crates/codexbar-app/src/widgets/cards.rs b/crates/codexbar-app/src/widgets/cards.rs index 58cd3177..6b2cd39c 100644 --- a/crates/codexbar-app/src/widgets/cards.rs +++ b/crates/codexbar-app/src/widgets/cards.rs @@ -468,6 +468,21 @@ fn status_color(status: Option<&str>) -> &'static str { StatusTone::of(status).card_color() } +/// The tile's SUS: a firm score in its band's color, an early one subtle and uncolored. +fn sus_line(view: &TileView) -> Option { + let mut score = text(view.sus.clone()?); + score["color"] = json!(match view.sus_score { + Some(90..=100) => "Good", + Some(75..=89) => "Warning", + Some(_) => "Attention", + None => "Default", + }); + if view.sus_score.is_none() && view.sus_early.is_some() { + score["isSubtle"] = json!(true); + } + Some(score) +} + /// One tile as a card container. Tapping it opens CodexBar on its account and metric. fn tile(view: &TileView, compact: bool) -> Value { let mut items = Vec::new(); @@ -476,19 +491,7 @@ fn tile(view: &TileView, compact: bool) -> Value { name["size"] = json!("Small"); name["wrap"] = json!(false); items.push(name); - if let Some(sus) = &view.sus { - let mut score = text(sus.clone()); - score["color"] = json!(match view.sus_score { - Some(90..=100) => "Good", - Some(75..=89) => "Warning", - Some(_) => "Attention", - None => "Default", - }); - if view.sus_score.is_none() && view.sus_early.is_some() { - score["isSubtle"] = json!(true); - } - items.push(score); - } + items.extend(sus_line(view)); if let Some(big) = &view.big { let mut figure = text(big.clone()); figure["size"] = json!(if compact { "Medium" } else { "Large" }); diff --git a/crates/codexbar-providers/src/muse.rs b/crates/codexbar-providers/src/muse.rs index cd85f8f9..b63fc742 100644 --- a/crates/codexbar-providers/src/muse.rs +++ b/crates/codexbar-providers/src/muse.rs @@ -136,6 +136,15 @@ fn safe_text(value: Option<&Value>, max: usize, valid: impl Fn(char) -> bool) -> .map(str::to_owned) } +/// The week forecasts from the shared weekly maturity; the five-hour window keeps its two minutes. +fn maturity(length: Duration) -> Duration { + if is_week_length(length) { + WEEKLY_MATURITY + } else { + Duration::minutes(2) + } +} + /// Parse only the documented quota fields. Public for sanitized producer-to-consumer qualification. pub fn parse(text: &str, fetched: DateTime, id: &str, label: &str) -> Result { if text.len() > OUTPUT_LIMIT { @@ -180,14 +189,8 @@ pub fn parse(text: &str, fetched: DateTime, id: &str, label: &str) -> Resul }; let elapsed = length - (resets_at - observed); let used = percent / 100.0; - // The week forecasts from the shared weekly maturity; the five-hour window keeps its two minutes. - let maturity = if is_week_length(length) { - WEEKLY_MATURITY - } else { - Duration::minutes(2) - }; - let pace = - (elapsed >= maturity).then(|| Pace::per_hour(used * 3600.0 / elapsed.num_milliseconds() as f64 * 1000.0)); + let pace = (elapsed >= maturity(length)) + .then(|| Pace::per_hour(used * 3600.0 / elapsed.num_milliseconds() as f64 * 1000.0)); metrics.push(Metric::Window { label: label.into(), used, diff --git a/crates/codexbar-providers/src/pace.rs b/crates/codexbar-providers/src/pace.rs index b52168ed..d57f72d4 100644 --- a/crates/codexbar-providers/src/pace.rs +++ b/crates/codexbar-providers/src/pace.rs @@ -11,15 +11,18 @@ const MIN_ELAPSED_FRACTION: f64 = 0.1; /// Usage so far divided by time elapsed in the period `[start, resets_at)`, or `None` while the period is too young. pub fn elapsed_pace(used: f64, start: DateTime, resets_at: DateTime, now: DateTime) -> Option { - let length = resets_at - start; - let minimum = if is_week_length(length) { + elapsed_pace_after(used, start, resets_at, now, maturity(resets_at - start)) +} + +/// How long a period of `length` runs before it projects. +fn maturity(length: Duration) -> Duration { + if is_week_length(length) { WEEKLY_MATURITY } else { Duration::minutes(MIN_ELAPSED_MINUTES).max(Duration::seconds( (length.num_seconds() as f64 * MIN_ELAPSED_FRACTION) as i64, )) - }; - elapsed_pace_after(used, start, resets_at, now, minimum) + } } fn elapsed_pace_after( From 2d324f348cbfb2276f676b34e1cd0393ab87b9c3 Mon Sep 17 00:00:00 2001 From: HemSoft Date: Mon, 26 Oct 2026 22:37:01 -0400 Subject: [PATCH 3/4] test: expect the early SUS in the Gemini weekly capture The 11h 59m 59s Gemini Models weekly fixture now renders "SUS 42 early", so the capture harness asserted a stale "SUS n/a" and aborted before saving. Co-Authored-By: Claude Opus 5.5 --- crates/codexbar-app/src/self_capture/gemini_weekly.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/codexbar-app/src/self_capture/gemini_weekly.rs b/crates/codexbar-app/src/self_capture/gemini_weekly.rs index 14c6d7f3..c859779f 100644 --- a/crates/codexbar-app/src/self_capture/gemini_weekly.rs +++ b/crates/codexbar-app/src/self_capture/gemini_weekly.rs @@ -33,7 +33,8 @@ pub(super) async fn run( Some(if mature { "Gemini Models weekly: SUS 42, red" } else { - "Gemini Models weekly: SUS n/a, unavailable" + // 11h 59m 59s: an early score, without a forecast until 12 hours. + "Gemini Models weekly: SUS 42 early, early estimate" }) ); let ring = window.find("focus-share"); From 771517a7bef4fab67d98dace09f696fed03bebb7 Mon Sep 17 00:00:00 2001 From: HemSoft Date: Mon, 26 Oct 2026 23:03:32 -0400 Subject: [PATCH 4/4] test: keep production coverage for the early SUS paths The metrics gate found production line and branch coverage lower: the badge tooltip text moved into a helper called only on hover, the tile's green and red SUS colors were untested, and early()'s pace swap had an unreachable implicit else. Build the tooltip eagerly again, test both tile colors and the n/a tile, and move the pace swap into Metric::with_pace with a test of every variant. Co-Authored-By: Claude Opus 5.5 --- crates/codexbar-app/src/sus.rs | 3 +- crates/codexbar-app/src/widgets/cards.rs | 8 ++++ crates/codexbar-core/src/metric.rs | 47 ++++++++++++++++++++++++ crates/codexbar-core/src/sus.rs | 8 ++-- 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/crates/codexbar-app/src/sus.rs b/crates/codexbar-app/src/sus.rs index cba914ba..b80937cf 100644 --- a/crates/codexbar-app/src/sus.rs +++ b/crates/codexbar-app/src/sus.rs @@ -17,6 +17,7 @@ pub fn badge(reading: Option, id: &str, label: &str, cx: &App) -> AnyEl let (color, band) = tone(reading, cx); let early = reading.is_some_and(Reading::is_early); let spoken = format!("{label}: {text}, {band}"); + let tooltip = tooltip(early); div() .id(ElementId::Name(id.to_owned().into())) .test_support() @@ -26,7 +27,7 @@ pub fn badge(reading: Option, id: &str, label: &str, cx: &App) -> AnyEl .when(!early, |this| this.font_semibold()) .text_color(color) .child(text) - .tooltip(move |window, cx| Tooltip::new(tooltip(early)).build(window, cx)) + .tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx)) .into_any_element() } /// The color and spoken band. An early score is provisional: it takes no band color, and the label says so instead. diff --git a/crates/codexbar-app/src/widgets/cards.rs b/crates/codexbar-app/src/widgets/cards.rs index 6b2cd39c..8826975f 100644 --- a/crates/codexbar-app/src/widgets/cards.rs +++ b/crates/codexbar-app/src/widgets/cards.rs @@ -957,10 +957,18 @@ mod tests { let score = &tile(&view, false)["items"][1]; assert_eq!(score["color"], "Warning"); assert!(score.get("isSubtle").is_none()); + for (firm, color) in [(90, "Good"), (74, "Attention")] { + snapshot.accounts[0].metrics[0].sus = Some(firm); + let view = TileView::of(&snapshot.accounts[0], Some("weekly"), TileMode::Automatic, now()); + assert_eq!(tile(&view, false)["items"][1]["color"], color, "{firm}"); + } snapshot.accounts[0].metrics[0].sus = None; snapshot.accounts[0].metrics[0].sus_early = Some(101); let view = TileView::of(&snapshot.accounts[0], Some("weekly"), TileMode::Automatic, now()); assert_eq!(view.sus.as_deref(), Some("SUS n/a")); + let score = &tile(&view, false)["items"][1]; + assert_eq!(score["color"], "Default"); + assert!(score.get("isSubtle").is_none(), "n/a is not an early score"); } #[test] diff --git a/crates/codexbar-core/src/metric.rs b/crates/codexbar-core/src/metric.rs index c9b922b5..0ae81b86 100644 --- a/crates/codexbar-core/src/metric.rs +++ b/crates/codexbar-core/src/metric.rs @@ -393,6 +393,15 @@ impl Metric { Some(Duration::seconds((hours * 3600.0).round() as i64)) } + /// This metric with `pace`, for a projection that must not change the metric itself, such as an early SUS. + /// Metrics without a pace are returned unchanged. + pub fn with_pace(mut self, new: Pace) -> Self { + if let Self::Window { pace, .. } | Self::Quota { pace, .. } | Self::Spend { pace, .. } = &mut self { + *pace = Some(new); + } + self + } + /// The fraction of the limit used by the reset if the current pace holds (#83): above 1 when it runs out first. /// `None` without a pace, a limit or a reset still ahead, so nothing is projected from too little information. pub fn projected_at_reset(&self, now: DateTime) -> Option { @@ -621,6 +630,44 @@ mod tests { assert!(metric.exhausts_before_reset(now())); } + #[test] + fn with_pace_sets_only_metrics_that_have_a_pace() { + let pace = Pace::per_hour(0.02); + let window = Metric::Window { + label: "Weekly".into(), + used: 0.1, + resets_at: now() + Duration::hours(10), + pace: None, + length: Some(Duration::days(7)), + }; + let quota = Metric::Quota { + label: "Requests".into(), + used: 10, + limit: 100, + resets_at: now() + Duration::hours(10), + pace: None, + length: None, + }; + let spend = Metric::Spend { + label: "Spend".into(), + spent: Money::from_cents(500), + limit: Some(Money::from_cents(1000)), + resets_at: Some(now() + Duration::hours(10)), + pace: None, + }; + for metric in [window, quota, spend] { + let paced = metric.clone().with_pace(pace); + assert!(paced.projected_at_reset(now()).is_some(), "{}", metric.label()); + assert_eq!(metric.projected_at_reset(now()), None, "the original is unchanged"); + } + let balance = Metric::Balance { + label: "Credits".into(), + remaining: Money::from_cents(1842), + burn_per_day: None, + }; + assert_eq!(balance.clone().with_pace(pace), balance); + } + #[test] fn projected_at_reset_adds_the_pace_until_the_reset() { let metric = Metric::Window { diff --git a/crates/codexbar-core/src/sus.rs b/crates/codexbar-core/src/sus.rs index 41723a91..7c1ea926 100644 --- a/crates/codexbar-core/src/sus.rs +++ b/crates/codexbar-core/src/sus.rs @@ -132,11 +132,9 @@ pub fn early(metric: &Metric, now: DateTime) -> Option { if !(EARLY_SUS..WEEKLY_MATURITY).contains(&age) { return None; } - let mut provisional = metric.clone(); - // Only windows and quotas carry the length a week needs. - if let Metric::Window { pace, .. } | Metric::Quota { pace, .. } = &mut provisional { - *pace = Some(Pace::per_hour(metric.used_fraction()? / hours(age))); - } + let provisional = metric + .clone() + .with_pace(Pace::per_hour(metric.used_fraction()? / hours(age))); score(&provisional, now, length, reset) } /// The score to show: firm once mature, early before that, `None` for `SUS n/a`.