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/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"); diff --git a/crates/codexbar-app/src/sus.rs b/crates/codexbar-app/src/sus.rs index 7a37fa2d..b80937cf 100644 --- a/crates/codexbar-app/src/sus.rs +++ b/crates/codexbar-app/src/sus.rs @@ -3,42 +3,60 @@ 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, + 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(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"), - None => (cx.theme().muted_foreground, "unavailable"), - }; +pub fn badge(reading: Option, id: &str, label: &str, cx: &App) -> AnyElement { + let text = reading.map_or_else(|| "SUS n/a".into(), Reading::text); + let (color, band) = tone(reading, cx); + let early = reading.is_some_and(Reading::is_early); 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 = tooltip(early); div() .id(ElementId::Name(id.to_owned().into())) .test_support() .aria_label(spoken) .flex_none() .text_xs() - .font_semibold() + .when(!early, |this| this.font_semibold()) .text_color(color) .child(text) .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. +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 => ( + 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"), + } +} +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() + } 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() + } +} pub fn metric( account: &AccountSnapshot, metric: &Metric, @@ -53,7 +71,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..8826975f 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, } } } @@ -462,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(); @@ -470,16 +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", - }); - 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" }); @@ -695,6 +707,7 @@ mod tests { projected_percent: None, outlook: None, sus: None, + sus_early: None, sus_eligible: false, }], } @@ -896,6 +909,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 +937,40 @@ 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()); + 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] 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/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 8f8cd78f..7c1ea926 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,36 @@ 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 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`. +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 +363,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..b63fc742 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}; @@ -133,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 { @@ -177,7 +189,7 @@ 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)) + 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(), @@ -264,6 +276,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..d57f72d4 100644 --- a/crates/codexbar-providers/src/pace.rs +++ b/crates/codexbar-providers/src/pace.rs @@ -1,22 +1,31 @@ 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, - )); - elapsed_pace_after(used, start, resets_at, now, minimum) + elapsed_pace_after(used, start, resets_at, now, maturity(resets_at - start)) } -/// Projects after an explicit maturity point. Antigravity weekly pools mature with SUS at 12 hours (#262). -pub fn elapsed_pace_after( +/// 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, + )) + } +} + +fn elapsed_pace_after( used: f64, start: DateTime, resets_at: DateTime, @@ -59,6 +68,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.