diff --git a/CHANGELOG.md b/CHANGELOG.md index 33fd3cc..7b39ebb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,14 @@ publishing empty notes. ### Added +- **The Usage view can show the plan's own meter.** m swaps the + token table for Claude's 5-hour metering windows — per day, how many were + started and the peak utilisation each reached, with the deepest window + drawn as a bar against the cap itself. Reconstructed from the samples + Claude Desktop already keeps in `plan-usage-history.json`; timestamps and + percentages are all that is read, the org id in the file is never kept, and + a machine without the file gets the absence stated rather than a zero. The + window count is a floor — samples exist only while Claude Desktop runs. - **A SPEND card that prices seats, and a TOKEN COST card that prices tokens.** The Overview's old SPEND figure — the window's tokens at API list rates — now sits under the name it deserved, **TOKEN COST**. The **SPEND** card answers diff --git a/docs/guide/dashboard.md b/docs/guide/dashboard.md index a1ae6e0..0277744 100644 --- a/docs/guide/dashboard.md +++ b/docs/guide/dashboard.md @@ -37,6 +37,7 @@ and the body needs the room more than the nav does. | enter | Switch pane, on a view that has two | | d | Token detail line under each row, on and off | | u | Switch the Overview chart between spend and tokens | +| m | Switch the Usage view between tokens and metering windows | | [/] | Move the chart cursor one bucket | | backspace | Drop the chart cursor | | w | Regroup the charts by day, week or month | @@ -205,6 +206,36 @@ Every kind of token is on the detail line under each row, which is what output rate — and both are inside **TOTAL**, so before they had a line of their own the visible columns did not add up to the total beside them. +### Metering windows + +m swaps this view for the plan's own meter. Claude plans are +enforced in 5-hour windows — how many get started, and how deep each runs — +and tokens say nothing about either. Claude Desktop samples that meter every +few minutes into `plan-usage-history.json`; surface reconstructs the windows +from those samples and shows, per day, how many were started and the peak each +reached: + +| Column | Meaning | +|---|---| +| **DAY** | Window start date, UTC | +| **WINDOWS** | 5-hour windows started that day | +| **AVG PEAK** | Mean of the day's window peaks | +| **MAX PEAK** | The day's deepest window, `▲` amber from 90% | +| **VS CAP** | The deepest window as a bar against the cap itself — half a bar is half way to the meter, on every machine | + +The title carries the whole story in one line: window count, the span the +samples cover, average peak, hottest ever. A window that reaches 100% and +keeps going is where extra usage starts billing, so the peaks here are the +early warning the token counts cannot give. + +Three honest limits: the samples exist only while Claude Desktop runs, so the +window count is a floor; the reconstruction is heuristic (a sharp utilisation +drop is read as a reset); and it is Claude-only — Codex's equivalent lives in +its transcripts' `rate_limits` and is not read yet. A machine without the +history file gets that stated, never a zero that would read as idleness. +Timestamps and percentages are all that is read; the org id in the file is +never kept. + **The chart above this table is stacked by model, not by tool, and a model is the same colour in both.** That is the point of keying them the same way: a segment in the chart and a row in the table are visibly one thing. The swatch beside each diff --git a/src/app.rs b/src/app.rs index 6b1dab6..4c6ee87 100644 --- a/src/app.rs +++ b/src/app.rs @@ -214,6 +214,18 @@ impl SessionRow { } } +/// One day of the plan's own meter: how many 5-hour windows started, and how +/// deep they ran. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MeterDay { + pub day: String, + pub windows: usize, + /// Mean of the day's window peaks, percent. + pub avg_peak: u64, + /// Deepest window of the day, percent. + pub max_peak: u64, +} + /// A subscription against what the same usage would have cost on API rates. #[derive(Debug, Clone)] pub struct SubscriptionRow { @@ -286,6 +298,10 @@ pub struct App { /// tables that have one. On by default: the figures it carries are billed, /// and they were invisible before it existed. pub detail: bool, + /// Whether the Usage view shows the plan's own meter — the 5-hour windows + /// and the peak each reached — instead of the token table. Off by + /// default: tokens are what the view is named for. + pub metering_view: bool, pub should_quit: bool, pub status_line: Option, @@ -323,6 +339,7 @@ impl App { bucket_back: None, unit: Unit::Spend, detail: true, + metering_view: false, should_quit: false, status_line: None, tools: Vec::new(), @@ -812,6 +829,9 @@ impl App { Tab::Overview => 0, Tab::Tools => self.tools.len(), Tab::Sites => self.site_count(), + // The meter has nothing to select: its rows are days, and no + // second pane hangs off them. + Tab::Usage if self.metering_view => 0, Tab::Usage => self.models.len(), // Nothing to select over when there are no prices; the footer should // not advertise a movement that does nothing. @@ -1035,6 +1055,48 @@ impl App { ); } + /// Swap the Usage view between tokens and the plan's own meter. A no-op + /// on every other view, so the key cannot invisibly re-arm a mode the + /// reader is not looking at. + pub fn toggle_metering(&mut self) { + if self.tab != Tab::Usage { + return; + } + self.metering_view = !self.metering_view; + self.status_line = Some( + if self.metering_view { + "metering windows" + } else { + "tokens" + } + .to_string(), + ); + } + + /// The plan's own meter, grouped by day, oldest first. + /// + /// Computed on read rather than cached in `rebuild`: a fortnight of + /// samples yields a few dozen windows, which is nothing next to the + /// ledger walks the cache exists for. + pub fn metering_days(&self) -> Vec { + let mut by_day: BTreeMap> = BTreeMap::new(); + for window in &self.scan.metering.windows { + by_day + .entry(window.day.clone()) + .or_default() + .push(window.peak); + } + by_day + .into_iter() + .map(|(day, peaks)| MeterDay { + windows: peaks.len(), + avg_peak: (peaks.iter().sum::() as f64 / peaks.len() as f64).round() as u64, + max_peak: peaks.iter().copied().max().unwrap_or(0), + day, + }) + .collect() + } + pub fn cycle_granularity(&mut self) { self.granularity = self.granularity.next(); // Regrouping rebuilds the buckets, so "three ago" now points at a diff --git a/src/demo.rs b/src/demo.rs index 9eadadd..f52c54b 100644 --- a/src/demo.rs +++ b/src/demo.rs @@ -28,6 +28,7 @@ use chrono::{Datelike, Duration, Utc, Weekday}; use crate::ledger::{Ledger, Tokens}; +use crate::scan::meter; #[cfg(feature = "sqlite")] use crate::scan::sites; use crate::scan::{tooling, usage, Scan, Timings}; @@ -51,6 +52,7 @@ pub fn scan() -> (Scan, Timings) { #[cfg(feature = "sqlite")] sites: sites(), usage: usage(), + metering: metering(), failed: Vec::new(), demo: true, }, @@ -209,6 +211,36 @@ fn sites() -> sites::Sites { } } +// ----------------------------------------------------------------- metering + +/// Fourteen days of 5-hour windows: mostly shallow, two hot days, one brush +/// with the cap — the states a viewer needs to see, none of them invented +/// past what a real fortnight looks like. +fn metering() -> meter::Metering { + let mut rng = Rng(SEED ^ 0x11E7E4); + let today = Utc::now().date_naive(); + let mut windows = Vec::new(); + for days_ago in (0..14).rev() { + let day = (today - Duration::days(days_ago)).format("%Y-%m-%d"); + for _ in 0..rng.range(1, 3) { + let peak = match rng.range(1, 6) { + 1 => rng.range(45, 92), // a hot window + _ => rng.range(2, 35), // an ordinary one + }; + windows.push(meter::MeterWindow { + day: day.to_string(), + peak, + }); + } + } + meter::Metering { + from: windows.first().map(|w| w.day.clone()), + to: windows.last().map(|w| w.day.clone()), + windows, + found: true, + } +} + // ------------------------------------------------------------------- usage /// One tool's models, and the weight each carries in that tool's traffic. diff --git a/src/main.rs b/src/main.rs index 473a2d7..d4016b3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -233,6 +233,7 @@ fn handle_key(app: &mut App, key: event::KeyEvent) { KeyCode::Char('w') => app.cycle_granularity(), KeyCode::Char('d') => app.toggle_detail(), KeyCode::Char('u') => app.toggle_unit(), + KeyCode::Char('m') => app.toggle_metering(), KeyCode::Enter => app.toggle_focus(), KeyCode::Char('[') => app.move_bucket(1), KeyCode::Char(']') => app.move_bucket(-1), diff --git a/src/scan/meter.rs b/src/scan/meter.rs new file mode 100644 index 0000000..185add6 --- /dev/null +++ b/src/scan/meter.rs @@ -0,0 +1,272 @@ +//! Claude's 5-hour metering windows, from the usage history Claude Desktop +//! already keeps beside its config. +//! +//! Plans are metered in two units: how *deep* each 5-hour window runs, and how +//! *many* windows get started. The Usage view's token table answers neither — +//! tokens say nothing about where the plan's own meter stands. Claude Desktop +//! samples that meter every few minutes into `plan-usage-history.json` +//! (`{t, org, u: {fh, sd}}`: epoch millis, org id, and the 5-hour / 7-day +//! window utilisation as percentages), which is enough to reconstruct each +//! window and the peak it reached. +//! +//! # What is read, and what is not +//! +//! Timestamps and two percentages. The org id in each sample is not kept, +//! shown, or written anywhere — the file is parsed for `t` and `u.fh` only. +//! Absent file (no Claude Desktop, or another platform layout) scans to an +//! empty result and the view says so, rather than rendering a zero that would +//! read as "no usage". +//! +//! # Windows are reconstructed, not reported +//! +//! The file stores samples, not windows. A window is inferred to start when +//! utilisation rises from zero, resumes after a gap of at least the window +//! length, or falls sharply (a reset while the app kept sampling). Samples +//! only exist while Claude Desktop runs, so the count is a floor — windows +//! started while it was closed are invisible — which is the honest direction +//! for a "how close to the cap am I" signal to err. + +use std::path::{Path, PathBuf}; + +/// The 5-hour window length, for gap detection, in milliseconds. +const WINDOW_MS: i64 = 5 * 60 * 60 * 1000; + +/// A drop this many percentage points (while sampling stayed continuous) is a +/// meter reset, not noise: utilisation only decays with time, and a decay +/// this steep inside one sampling gap means a new window began. +const RESET_DROP: u64 = 30; + +/// One reconstructed 5-hour window. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MeterWindow { + /// `YYYY-MM-DD` (UTC) the window started. + pub day: String, + /// Highest utilisation the window reached, in percent. + pub peak: u64, +} + +/// Every window the history file can testify to. +#[derive(Debug, Clone, Default)] +pub struct Metering { + pub windows: Vec, + /// First and last sample days, so the view can say what span the count + /// covers rather than implying it covers the usage window. + pub from: Option, + pub to: Option, + /// The history file was found and parsed. `false` renders as an honest + /// absence, never as zero windows. + pub found: bool, +} + +impl Metering { + /// Peak utilisation across every window, in percent. + pub fn hottest(&self) -> u64 { + self.windows.iter().map(|w| w.peak).max().unwrap_or(0) + } + + /// Mean of the per-window peaks, in percent. + pub fn average_peak(&self) -> u64 { + if self.windows.is_empty() { + return 0; + } + let sum: u64 = self.windows.iter().map(|w| w.peak).sum(); + (sum as f64 / self.windows.len() as f64).round() as u64 + } +} + +/// Read the metering history this machine has, if any. +pub fn scan() -> Metering { + let Some(home) = crate::paths::home() else { + return Metering::default(); + }; + candidates(&home) + .iter() + .find(|p| p.is_file()) + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|raw| parse(&raw)) + .unwrap_or_default() +} + +/// Where Claude Desktop keeps the file, per platform. Checked in order. +fn candidates(home: &Path) -> Vec { + vec![ + home.join("AppData/Roaming/Claude/plan-usage-history.json"), + home.join("Library/Application Support/Claude/plan-usage-history.json"), + home.join(".config/Claude/plan-usage-history.json"), + ] +} + +/// One sample: when, and how used the 5-hour window was. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Sample { + /// Epoch milliseconds. + t: i64, + /// 5-hour window utilisation, percent. + fh: u64, +} + +/// Parse the history file into windows. +pub fn parse(raw: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(raw).ok()?; + let samples: Vec = value + .get("samples")? + .as_array()? + .iter() + .filter_map(|s| { + Some(Sample { + t: s.get("t")?.as_i64()?, + fh: s.get("u")?.get("fh")?.as_u64()?, + }) + }) + .collect(); + if samples.is_empty() { + return None; + } + + Some(Metering { + windows: windows_of(&samples), + from: Some(day_of(samples.first()?.t)), + to: Some(day_of(samples.last()?.t)), + found: true, + }) +} + +/// Reconstruct windows from the sample stream. See the module doc for the +/// three start conditions; the peak is simply the highest sample inside. +fn windows_of(samples: &[Sample]) -> Vec { + let mut windows: Vec = Vec::new(); + let mut current: Option = None; + let mut prev: Option = None; + + for &sample in samples { + let starts = match prev { + None => sample.fh > 0, + Some(prev) => { + sample.fh > 0 + && (prev.fh == 0 + || sample.t - prev.t >= WINDOW_MS + || sample.fh + RESET_DROP < prev.fh) + } + }; + if starts { + windows.extend(current.take()); + current = Some(MeterWindow { + day: day_of(sample.t), + peak: sample.fh, + }); + } else if let Some(window) = &mut current { + window.peak = window.peak.max(sample.fh); + } + prev = Some(sample); + } + windows.extend(current); + windows +} + +/// `YYYY-MM-DD` in UTC from epoch milliseconds. +fn day_of(millis: i64) -> String { + chrono::DateTime::from_timestamp_millis(millis) + .map(|d| d.format("%Y-%m-%d").to_string()) + .unwrap_or_else(|| "unknown".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// Minutes to epoch millis, from an arbitrary but fixed origin. + fn at(minutes: i64) -> i64 { + 1_753_920_000_000 + minutes * 60_000 + } + + fn history(samples: &[(i64, u64)]) -> String { + json!({ + "version": 2, + "samples": samples.iter().map(|(t, fh)| json!({ + "t": t, "org": "not-kept", "u": {"fh": fh, "sd": 1} + })).collect::>() + }) + .to_string() + } + + #[test] + fn a_rise_from_zero_starts_a_window_and_its_peak_is_kept() { + let raw = history(&[ + (at(0), 0), + (at(5), 12), + (at(10), 40), + (at(15), 33), // decay inside the same window + ]); + let m = parse(&raw).unwrap(); + assert!(m.found); + assert_eq!(m.windows.len(), 1); + assert_eq!(m.windows[0].peak, 40); + } + + #[test] + fn a_sharp_drop_is_a_reset_and_a_shallow_one_is_decay() { + let raw = history(&[ + (at(0), 50), + (at(5), 55), + (at(10), 15), // 40-point drop: a new window + (at(15), 35), // 20-point rise: same window + ]); + let m = parse(&raw).unwrap(); + assert_eq!(m.windows.len(), 2); + assert_eq!(m.windows[0].peak, 55); + assert_eq!(m.windows[1].peak, 35); + } + + #[test] + fn a_gap_of_a_window_length_starts_a_new_window() { + let raw = history(&[ + (at(0), 20), + (at(5), 25), + (at(5 + 5 * 60), 10), // five hours later + ]); + let m = parse(&raw).unwrap(); + assert_eq!(m.windows.len(), 2); + } + + #[test] + fn idle_samples_start_nothing() { + let raw = history(&[(at(0), 0), (at(5), 0), (at(10), 0)]); + let m = parse(&raw).unwrap(); + assert!(m.windows.is_empty()); + assert_eq!(m.hottest(), 0); + } + + #[test] + fn summary_figures_average_the_peaks_not_the_samples() { + let raw = history(&[ + (at(0), 10), + (at(5), 60), // window 1 peaks at 60 + (at(10), 2), // a 58-point drop: reset, window 2 + (at(15), 40), + ]); + let m = parse(&raw).unwrap(); + assert_eq!(m.windows.len(), 2); + assert_eq!(m.average_peak(), 50, "(60 + 40) / 2"); + assert_eq!(m.hottest(), 60); + } + + #[test] + fn an_absent_or_malformed_file_is_not_found_rather_than_zero() { + assert!(parse("{not json").is_none()); + assert!(parse(r#"{"version": 2, "samples": []}"#).is_none()); + let m = Metering::default(); + assert!(!m.found, "absence must be distinguishable from idleness"); + } + + #[test] + fn the_org_id_is_never_kept() { + let raw = history(&[(at(0), 10)]); + let m = parse(&raw).unwrap(); + let rendered = format!("{m:?}"); + assert!( + !rendered.contains("not-kept"), + "org id leaked into the result" + ); + } +} diff --git a/src/scan/mod.rs b/src/scan/mod.rs index f165539..276b95d 100644 --- a/src/scan/mod.rs +++ b/src/scan/mod.rs @@ -12,6 +12,7 @@ //! release profile deliberately does not set `panic = "abort"`. pub mod apps; +pub mod meter; #[cfg(feature = "sqlite")] pub mod sites; pub mod tooling; @@ -32,6 +33,8 @@ pub struct Scan { #[cfg(feature = "sqlite")] pub sites: sites::Sites, pub usage: usage::Usage, + /// Claude's 5-hour metering windows, from Claude Desktop's own samples. + pub metering: meter::Metering, /// Sections that panicked, by name. Empty is the normal case. pub failed: Vec<&'static str>, /// Built by [`crate::demo`] rather than read off this machine. Never true @@ -85,6 +88,9 @@ pub fn run(config: &Config, state_dir: &Path) -> (Scan, Timings) { .unwrap_or_default(); timings.usage_ms = mark.elapsed().as_millis(); + // One small file, read whole; not worth a timing of its own. + let metering = section("metering", &mut failed, meter::scan).unwrap_or_default(); + timings.total_ms = started.elapsed().as_millis(); ( @@ -94,6 +100,7 @@ pub fn run(config: &Config, state_dir: &Path) -> (Scan, Timings) { #[cfg(feature = "sqlite")] sites, usage, + metering, failed, demo: false, }, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 288041b..72943e2 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -858,6 +858,110 @@ fn draw_tools(frame: &mut Frame, area: Rect, app: &App) -> Option { row_hits(area, &table_state, 1) } +/// The plan's own meter: 5-hour windows per day and the peak each reached. +/// +/// This is the number the plan is actually enforced in — the token table says +/// how much work was done, this says how close that work came to the cap. A +/// window at 100% that keeps going is when extra usage starts billing, so the +/// peaks here are the early warning the token counts cannot give. +/// +/// Claude only, from Claude Desktop's own samples; a machine without them +/// gets the absence stated, never a zero that would read as idleness. The +/// count is a floor — samples exist only while the app runs. +fn draw_metering(frame: &mut Frame, area: Rect, app: &App) { + let metering = &app.scan.metering; + if !metering.found { + frame.render_widget( + Paragraph::new(vec![ + Line::from(Span::styled( + "No metering history on this machine.", + Style::default().fg(theme::TEXT), + )), + Line::from(Span::styled( + "Claude Desktop samples its plan meter into plan-usage-history.json;", + Style::default().fg(theme::DIM), + )), + Line::from(Span::styled( + "without it there is nothing to reconstruct. [m] returns to tokens.", + Style::default().fg(theme::DIM), + )), + ]) + .block(panel("metering windows \u{b7} [m] tokens")), + area, + ); + return; + } + + let days = app.metering_days(); + let hottest = metering.hottest(); + + let rows: Vec = days + .iter() + .map(|d| { + // The bar is the day's deepest window against the cap itself — + // not against the hottest day — so half a bar always means half + // way to the meter, on every machine. + let bar = ((d.max_peak as f64 / 100.0) * 24.0).round() as usize; + let hot = d.max_peak >= 90; + let mark = if hot { + Span::styled("\u{25b2} ", Style::default().fg(theme::WARN)) + } else { + Span::raw("") + }; + Row::new(vec![ + Cell::from(d.day.clone()), + Cell::from(thousands(d.windows as u64)), + Cell::from(format!("{}%", d.avg_peak)), + Cell::from(Line::from(vec![ + mark, + Span::styled( + format!("{}%", d.max_peak), + Style::default().fg(if hot { theme::WARN } else { theme::TEXT }), + ), + ])), + Cell::from(Line::from(Span::styled( + "\u{2588}".repeat(bar), + Style::default().fg(if hot { theme::WARN } else { theme::SEQUENTIAL }), + ))), + ]) + }) + .collect(); + + let span = match (&metering.from, &metering.to) { + (Some(from), Some(to)) if from != to => format!("{from} \u{2192} {to}"), + (Some(from), _) => from.clone(), + _ => String::new(), + }; + let mut title = format!( + "claude 5-hour windows \u{b7} {} over {span} \u{b7} avg peak {}%", + metering.windows.len(), + metering.average_peak(), + ); + if hottest >= 90 { + title.push_str(&format!(" \u{b7} \u{25b2} hottest {hottest}%")); + } else { + title.push_str(&format!(" \u{b7} hottest {hottest}%")); + } + title.push_str(" \u{b7} [m] tokens"); + + let table = Table::new( + rows, + [ + Constraint::Length(12), + Constraint::Length(9), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Min(10), + ], + ) + .header(header(&[ + "DAY", "WINDOWS", "AVG PEAK", "MAX PEAK", "VS CAP", + ])) + .block(panel(&title)); + + frame.render_widget(table, area); +} + // ------------------------------------------------------------------- sites #[cfg(feature = "sqlite")] @@ -971,6 +1075,11 @@ fn draw_sites(frame: &mut Frame, area: Rect, _app: &App) -> Option { // ------------------------------------------------------------------- usage fn draw_usage(frame: &mut Frame, area: Rect, app: &App) -> Option { + if app.metering_view { + draw_metering(frame, area, app); + return None; + } + let rows = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Percentage(45), Constraint::Min(6)]) @@ -1915,6 +2024,7 @@ fn draw_help(frame: &mut Frame, area: Rect) { Line::from(" enter switch pane, where a view has two"), Line::from(" d token detail under each row"), Line::from(" u Overview chart: spend or tokens"), + Line::from(" m Usage view: tokens or metering windows"), Line::from(" [ / \u{5d} move the chart cursor a bucket"), Line::from(" backspace drop the chart cursor"), Line::from(" click a view, a table row, or a pane"), @@ -2040,6 +2150,7 @@ mod tests { window_days: 30, ..Default::default() }, + metering: Default::default(), failed: Vec::new(), demo: false, }; @@ -2117,6 +2228,7 @@ mod tests { #[cfg(feature = "sqlite")] sites: Default::default(), usage: Default::default(), + metering: Default::default(), failed: Vec::new(), demo: false, }; @@ -2249,6 +2361,73 @@ mod tests { assert!(!out.contains("$0.00")); } + /// `m` swaps the Usage view for the plan's own meter and back, shows the + /// per-day peaks, and does nothing on any other view. + #[test] + fn the_usage_view_toggles_to_metering_windows() { + let mut app = populated(); + app.set_tab(Tab::Overview); + app.toggle_metering(); + assert!(!app.metering_view, "a no-op off the Usage view"); + + app.set_tab(Tab::Usage); + app.scan.metering = crate::scan::meter::Metering { + windows: vec![ + crate::scan::meter::MeterWindow { + day: "2026-07-27".to_string(), + peak: 75, + }, + crate::scan::meter::MeterWindow { + day: "2026-07-27".to_string(), + peak: 43, + }, + crate::scan::meter::MeterWindow { + day: "2026-07-28".to_string(), + peak: 95, + }, + ], + from: Some("2026-07-27".to_string()), + to: Some("2026-07-28".to_string()), + found: true, + }; + app.toggle_metering(); + assert!(app.metering_view); + assert_eq!(app.row_count(), 0, "the meter has nothing to select"); + + let out = rendered(&app, 150, 30); + assert!(out.contains("claude 5-hour windows"), "the panel title"); + assert!(out.contains("MAX PEAK"), "the table header"); + assert!(out.contains("2026-07-27")); + assert!(out.contains("59%"), "avg of 75 and 43 on the hot day"); + assert!( + out.contains("\u{25b2} 95%"), + "90%+ is flagged, not just shown" + ); + assert!(out.contains("hottest 95%"), "the title carries the summary"); + + app.toggle_metering(); + assert!(!app.metering_view); + let out = rendered(&app, 150, 30); + assert!(out.contains("tokens by model"), "back to the token chart"); + } + + /// A machine without the history file states the absence — never a zero + /// that would read as an idle plan. + #[test] + fn missing_metering_history_is_an_absence_not_a_zero() { + let mut app = populated(); + app.set_tab(Tab::Usage); + assert!(!app.scan.metering.found, "the fixture has no history"); + app.toggle_metering(); + + let out = rendered(&app, 150, 30); + assert!(out.contains("No metering history on this machine.")); + assert!(!out.contains("MAX PEAK"), "no empty table pretending"); + } + + /// The SPEND card names what its dollars are: list-rate arithmetic when + /// every model is priced, the unpriced caveat when one is not — one line, + /// never both. /// The SPEND card prices seats, not tokens: nothing known shows `–` and /// says how to fix it, a detected plan is `≈` an estimate at list price, /// and a tool without any figure makes the total `≥` a floor. @@ -2283,6 +2462,7 @@ mod tests { ..Default::default() }, failed: Vec::new(), + metering: Default::default(), demo: false, }; let mut app = App::new( @@ -2329,6 +2509,7 @@ mod tests { ..Default::default() }, failed: Vec::new(), + metering: Default::default(), demo: false, }; let mut cost = CostConfig::default(); @@ -2427,6 +2608,7 @@ mod tests { window_days: 30, ..Default::default() }, + metering: Default::default(), failed: Vec::new(), demo: false, }; @@ -2472,6 +2654,7 @@ mod tests { window_days: 30, ..Default::default() }, + metering: Default::default(), failed: Vec::new(), demo: false, }; @@ -2523,6 +2706,7 @@ mod tests { window_days: 30, ..Default::default() }, + metering: Default::default(), failed: Vec::new(), demo: false, }; @@ -2974,6 +3158,7 @@ mod tests { window_days: 30, ..Default::default() }, + metering: Default::default(), failed: Vec::new(), demo: false, }; @@ -3251,6 +3436,7 @@ mod tests { window_days: 30, ..Default::default() }, + metering: Default::default(), failed: Vec::new(), demo: false, }; @@ -3329,6 +3515,7 @@ mod tests { window_days: 30, ..Default::default() }, + metering: Default::default(), failed: Vec::new(), demo: false, }; @@ -3352,6 +3539,7 @@ mod tests { #[cfg(feature = "sqlite")] sites: Default::default(), usage: Default::default(), + metering: Default::default(), failed: Vec::new(), demo: false, };