Skip to main content

ai_usagebar/
pacing.rs

1//! Pacing math — encodes claudebar's `calc_pacing` (claudebar:279-321) and
2//! `pace_color_for` (claudebar:212-219) as pure functions.
3//!
4//! Two parallel notions of "pacing":
5//! - **Ratio** — `actual_pct / elapsed_pct`, with a tolerance band (`PACE_TOLERANCE`).
6//!   Used for the `{*_pace}` and `{*_pace_pct}` placeholders. Capped at 999%.
7//! - **Point delta** — `actual_pct - elapsed_pct`, a signed integer.
8//!   Used for `{*_pace_indicator}`, `{*_pace_pts}`, `{*_pace_delta}`. No tolerance.
9//!
10//! Both are computed in one shot and returned as a `Pacing` struct so the
11//! caller can pick whichever placeholder it needs without re-running the math.
12
13use chrono::{DateTime, Utc};
14
15/// Default tolerance band (in percentage points) for the ratio-based pacing
16/// icon. Mirrors claudebar's default `PACE_TOLERANCE=5`.
17pub const DEFAULT_TOLERANCE: u32 = 5;
18
19/// A small enum captures the three visual pace states. Keeping the icon out
20/// of strings lets the TUI render `Style`-colored chars and the widget render
21/// raw glyphs without any string parsing on the other end.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Pace {
24    Ahead,
25    OnTrack,
26    Under,
27}
28
29impl Pace {
30    /// Single-char glyph used in claudebar's `{*_pace*}` placeholders.
31    pub fn glyph(self) -> &'static str {
32        match self {
33            Pace::Ahead => "↑",
34            Pace::OnTrack => "→",
35            Pace::Under => "↓",
36        }
37    }
38}
39
40/// Result of `calc_pacing` — all fields the caller might want to render.
41///
42/// Field naming mirrors the placeholders so the format-substitution layer is
43/// a trivial mapping.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct Pacing {
46    /// `{*_elapsed}` — integer percent of the window that has elapsed (0..=100).
47    pub elapsed_pct: i32,
48    /// `{*_pace}` — ratio-based icon, honors `tolerance`.
49    pub ratio_pace: Pace,
50    /// `{*_pace_indicator}` — point-based icon, no tolerance.
51    pub point_pace: Pace,
52    /// `{*_pace_delta}` — signed integer `usage_pct - elapsed_pct`.
53    pub delta: i32,
54    /// `{*_pace_pct}` — ratio-based label ("12% ahead" / "5% under" / "on track").
55    pub ratio_label: String,
56    /// `{*_pace_pts}` — point-based label ("12pts ahead" / "5pts under" / "on track").
57    pub point_label: String,
58}
59
60impl Pacing {
61    /// Neutral pacing for windows with no `resets_at` (e.g. vendors that don't
62    /// expose one). Matches claudebar's early-return value.
63    pub fn neutral() -> Self {
64        Self {
65            elapsed_pct: 0,
66            ratio_pace: Pace::OnTrack,
67            point_pace: Pace::OnTrack,
68            delta: 0,
69            ratio_label: "on track".into(),
70            point_label: "on track".into(),
71        }
72    }
73}
74
75/// Compute pacing for a usage window.
76///
77/// `usage_pct` is the vendor-reported utilization (0..=100, integer to match
78/// Claude's `utilization` field). `reset` is when the window rolls over;
79/// `now` is the reference time (passed in for testability). `window` is the
80/// window's total duration. `tolerance` is the ratio-tolerance band in
81/// percentage points (e.g. `5` for ±5%).
82pub fn calc(
83    usage_pct: i32,
84    reset: Option<DateTime<Utc>>,
85    now: DateTime<Utc>,
86    window: chrono::Duration,
87    tolerance: u32,
88) -> Pacing {
89    let Some(reset) = reset else {
90        return Pacing::neutral();
91    };
92    if window.num_seconds() <= 0 {
93        return Pacing::neutral();
94    }
95
96    let remaining = reset.signed_duration_since(now).num_seconds();
97    let total = window.num_seconds();
98    let mut elapsed_pct = (((total - remaining) * 100) / total) as i32;
99    elapsed_pct = elapsed_pct.clamp(0, 100);
100
101    // Point-based delta and label.
102    let delta = usage_pct - elapsed_pct;
103    let (point_pace, point_label) = if delta > 0 {
104        (Pace::Ahead, format!("{delta}pts ahead"))
105    } else if delta < 0 {
106        (Pace::Under, format!("{}pts under", -delta))
107    } else {
108        (Pace::OnTrack, "on track".to_string())
109    };
110
111    // Ratio-based icon and label (only meaningful once any time has elapsed).
112    let (ratio_pace, ratio_label) = if elapsed_pct > 0 {
113        let pacing_x100 = (usage_pct * 100) / elapsed_pct;
114        let tol = tolerance as i32;
115        if pacing_x100 > 100 + tol {
116            let dev = (pacing_x100 - 100).min(999);
117            (Pace::Ahead, format!("{dev}% ahead"))
118        } else if pacing_x100 < 100 - tol {
119            let dev = (100 - pacing_x100).min(999);
120            (Pace::Under, format!("{dev}% under"))
121        } else {
122            (Pace::OnTrack, "on track".to_string())
123        }
124    } else {
125        (Pace::OnTrack, "on track".to_string())
126    };
127
128    Pacing {
129        elapsed_pct,
130        ratio_pace,
131        point_pace,
132        delta,
133        ratio_label,
134        point_label,
135    }
136}
137
138/// Color band keyed on signed point delta. Mirrors claudebar's
139/// `pace_color_for` (claudebar:212-219). Returns one of the four severity
140/// tiers; the caller maps to a theme color.
141///
142/// `delta <= -10` → low (green); `-10..=0` → mid (yellow);
143/// `1..=9` → high (orange); `>= 10` → critical (red).
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum PaceSeverity {
146    Low,
147    Mid,
148    High,
149    Critical,
150}
151
152impl PaceSeverity {
153    /// Stable lowercase token used by JSON/CSS-facing presentation layers.
154    pub const fn as_str(self) -> &'static str {
155        match self {
156            Self::Low => "low",
157            Self::Mid => "mid",
158            Self::High => "high",
159            Self::Critical => "critical",
160        }
161    }
162}
163
164pub fn pace_severity(delta: i32) -> PaceSeverity {
165    if delta >= 10 {
166        PaceSeverity::Critical
167    } else if delta > 0 {
168        PaceSeverity::High
169    } else if delta >= -10 {
170        PaceSeverity::Mid
171    } else {
172        PaceSeverity::Low
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use chrono::TimeZone;
180
181    fn at(h: u32, m: u32) -> DateTime<Utc> {
182        Utc.with_ymd_and_hms(2026, 5, 23, h, m, 0).unwrap()
183    }
184
185    const FIVE_H: chrono::Duration = chrono::Duration::hours(5);
186
187    #[test]
188    fn missing_reset_returns_neutral() {
189        let p = calc(50, None, at(12, 0), FIVE_H, DEFAULT_TOLERANCE);
190        assert_eq!(p, Pacing::neutral());
191    }
192
193    #[test]
194    fn zero_window_returns_neutral() {
195        let p = calc(50, Some(at(12, 0)), at(12, 0), chrono::Duration::zero(), 5);
196        assert_eq!(p, Pacing::neutral());
197    }
198
199    #[test]
200    fn elapsed_clamps_to_zero_when_future_reset_beyond_window() {
201        // Reset is 6h away but window is 5h → "remaining > total" → negative
202        // elapsed → clamped to 0.
203        let now = at(12, 0);
204        let reset = now + chrono::Duration::hours(6);
205        let p = calc(10, Some(reset), now, FIVE_H, 5);
206        assert_eq!(p.elapsed_pct, 0);
207    }
208
209    #[test]
210    fn elapsed_clamps_to_hundred_when_past_reset() {
211        let now = at(12, 0);
212        let reset = now - chrono::Duration::hours(1);
213        let p = calc(50, Some(reset), now, FIVE_H, 5);
214        assert_eq!(p.elapsed_pct, 100);
215    }
216
217    #[test]
218    fn perfectly_even_pacing_is_on_track() {
219        // 50% elapsed, 50% usage → both metrics on track.
220        let now = at(12, 0);
221        let reset = now + chrono::Duration::minutes(150); // 2.5h remain of 5h
222        let p = calc(50, Some(reset), now, FIVE_H, DEFAULT_TOLERANCE);
223        assert_eq!(p.elapsed_pct, 50);
224        assert_eq!(p.delta, 0);
225        assert_eq!(p.ratio_pace, Pace::OnTrack);
226        assert_eq!(p.point_pace, Pace::OnTrack);
227        assert_eq!(p.ratio_label, "on track");
228        assert_eq!(p.point_label, "on track");
229    }
230
231    #[test]
232    fn ahead_of_pace_above_tolerance() {
233        // 50% elapsed, 70% usage → delta 20, ratio 140% → "40% ahead".
234        let now = at(12, 0);
235        let reset = now + chrono::Duration::minutes(150);
236        let p = calc(70, Some(reset), now, FIVE_H, 5);
237        assert_eq!(p.delta, 20);
238        assert_eq!(p.point_pace, Pace::Ahead);
239        assert_eq!(p.point_label, "20pts ahead");
240        assert_eq!(p.ratio_pace, Pace::Ahead);
241        assert_eq!(p.ratio_label, "40% ahead");
242    }
243
244    #[test]
245    fn under_pace_below_tolerance() {
246        // 50% elapsed, 30% usage → delta -20, ratio 60% → "40% under".
247        let now = at(12, 0);
248        let reset = now + chrono::Duration::minutes(150);
249        let p = calc(30, Some(reset), now, FIVE_H, 5);
250        assert_eq!(p.delta, -20);
251        assert_eq!(p.point_pace, Pace::Under);
252        assert_eq!(p.point_label, "20pts under");
253        assert_eq!(p.ratio_pace, Pace::Under);
254        assert_eq!(p.ratio_label, "40% under");
255    }
256
257    #[test]
258    fn within_tolerance_band_is_on_track_ratio_but_point_diverges() {
259        // 50% elapsed, 52% usage → ratio 104% (within ±5) → on track,
260        // BUT point delta is 2 → point_pace = Ahead, point_label "2pts ahead".
261        let now = at(12, 0);
262        let reset = now + chrono::Duration::minutes(150);
263        let p = calc(52, Some(reset), now, FIVE_H, DEFAULT_TOLERANCE);
264        assert_eq!(p.ratio_pace, Pace::OnTrack);
265        assert_eq!(p.ratio_label, "on track");
266        assert_eq!(p.point_pace, Pace::Ahead);
267        assert_eq!(p.point_label, "2pts ahead");
268    }
269
270    #[test]
271    fn ratio_clamps_at_999() {
272        // 1% elapsed, 60% usage → pacing_x100 = 6000, dev = 5900 → clamped to 999.
273        let now = at(12, 0);
274        let reset = now + chrono::Duration::minutes(297); // ~99% remaining → 1% elapsed
275        let p = calc(60, Some(reset), now, FIVE_H, 5);
276        assert_eq!(p.elapsed_pct, 1);
277        assert_eq!(p.ratio_label, "999% ahead");
278    }
279
280    #[test]
281    fn elapsed_zero_skips_ratio() {
282        // 0% elapsed → ratio code is skipped; ratio defaults to on track.
283        let now = at(12, 0);
284        let reset = now + FIVE_H; // full window remains
285        let p = calc(20, Some(reset), now, FIVE_H, 5);
286        assert_eq!(p.elapsed_pct, 0);
287        assert_eq!(p.ratio_pace, Pace::OnTrack);
288        // But point math still runs: delta = 20.
289        assert_eq!(p.delta, 20);
290        assert_eq!(p.point_pace, Pace::Ahead);
291    }
292
293    #[test]
294    fn severity_boundaries_match_claudebar() {
295        // claudebar: <= -10 green, -10..=0 yellow, 1..9 orange, >= 10 red
296        assert_eq!(pace_severity(-100), PaceSeverity::Low);
297        assert_eq!(pace_severity(-10), PaceSeverity::Mid); // -10 is in -10..=0 band
298        assert_eq!(pace_severity(-1), PaceSeverity::Mid);
299        assert_eq!(pace_severity(0), PaceSeverity::Mid);
300        assert_eq!(pace_severity(1), PaceSeverity::High);
301        assert_eq!(pace_severity(9), PaceSeverity::High);
302        assert_eq!(pace_severity(10), PaceSeverity::Critical);
303        assert_eq!(pace_severity(100), PaceSeverity::Critical);
304    }
305
306    #[test]
307    fn severity_tokens_are_stable_for_external_presenters() {
308        assert_eq!(PaceSeverity::Low.as_str(), "low");
309        assert_eq!(PaceSeverity::Mid.as_str(), "mid");
310        assert_eq!(PaceSeverity::High.as_str(), "high");
311        assert_eq!(PaceSeverity::Critical.as_str(), "critical");
312    }
313
314    #[test]
315    fn neutral_constructor_matches_default_state() {
316        let n = Pacing::neutral();
317        assert_eq!(n.elapsed_pct, 0);
318        assert_eq!(n.delta, 0);
319        assert_eq!(n.ratio_pace, Pace::OnTrack);
320        assert_eq!(n.point_pace, Pace::OnTrack);
321        assert_eq!(n.ratio_label, "on track");
322        assert_eq!(n.point_label, "on track");
323    }
324}