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
152pub fn pace_severity(delta: i32) -> PaceSeverity {
153    if delta >= 10 {
154        PaceSeverity::Critical
155    } else if delta > 0 {
156        PaceSeverity::High
157    } else if delta >= -10 {
158        PaceSeverity::Mid
159    } else {
160        PaceSeverity::Low
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use chrono::TimeZone;
168
169    fn at(h: u32, m: u32) -> DateTime<Utc> {
170        Utc.with_ymd_and_hms(2026, 5, 23, h, m, 0).unwrap()
171    }
172
173    const FIVE_H: chrono::Duration = chrono::Duration::hours(5);
174
175    #[test]
176    fn missing_reset_returns_neutral() {
177        let p = calc(50, None, at(12, 0), FIVE_H, DEFAULT_TOLERANCE);
178        assert_eq!(p, Pacing::neutral());
179    }
180
181    #[test]
182    fn zero_window_returns_neutral() {
183        let p = calc(50, Some(at(12, 0)), at(12, 0), chrono::Duration::zero(), 5);
184        assert_eq!(p, Pacing::neutral());
185    }
186
187    #[test]
188    fn elapsed_clamps_to_zero_when_future_reset_beyond_window() {
189        // Reset is 6h away but window is 5h → "remaining > total" → negative
190        // elapsed → clamped to 0.
191        let now = at(12, 0);
192        let reset = now + chrono::Duration::hours(6);
193        let p = calc(10, Some(reset), now, FIVE_H, 5);
194        assert_eq!(p.elapsed_pct, 0);
195    }
196
197    #[test]
198    fn elapsed_clamps_to_hundred_when_past_reset() {
199        let now = at(12, 0);
200        let reset = now - chrono::Duration::hours(1);
201        let p = calc(50, Some(reset), now, FIVE_H, 5);
202        assert_eq!(p.elapsed_pct, 100);
203    }
204
205    #[test]
206    fn perfectly_even_pacing_is_on_track() {
207        // 50% elapsed, 50% usage → both metrics on track.
208        let now = at(12, 0);
209        let reset = now + chrono::Duration::minutes(150); // 2.5h remain of 5h
210        let p = calc(50, Some(reset), now, FIVE_H, DEFAULT_TOLERANCE);
211        assert_eq!(p.elapsed_pct, 50);
212        assert_eq!(p.delta, 0);
213        assert_eq!(p.ratio_pace, Pace::OnTrack);
214        assert_eq!(p.point_pace, Pace::OnTrack);
215        assert_eq!(p.ratio_label, "on track");
216        assert_eq!(p.point_label, "on track");
217    }
218
219    #[test]
220    fn ahead_of_pace_above_tolerance() {
221        // 50% elapsed, 70% usage → delta 20, ratio 140% → "40% ahead".
222        let now = at(12, 0);
223        let reset = now + chrono::Duration::minutes(150);
224        let p = calc(70, Some(reset), now, FIVE_H, 5);
225        assert_eq!(p.delta, 20);
226        assert_eq!(p.point_pace, Pace::Ahead);
227        assert_eq!(p.point_label, "20pts ahead");
228        assert_eq!(p.ratio_pace, Pace::Ahead);
229        assert_eq!(p.ratio_label, "40% ahead");
230    }
231
232    #[test]
233    fn under_pace_below_tolerance() {
234        // 50% elapsed, 30% usage → delta -20, ratio 60% → "40% under".
235        let now = at(12, 0);
236        let reset = now + chrono::Duration::minutes(150);
237        let p = calc(30, Some(reset), now, FIVE_H, 5);
238        assert_eq!(p.delta, -20);
239        assert_eq!(p.point_pace, Pace::Under);
240        assert_eq!(p.point_label, "20pts under");
241        assert_eq!(p.ratio_pace, Pace::Under);
242        assert_eq!(p.ratio_label, "40% under");
243    }
244
245    #[test]
246    fn within_tolerance_band_is_on_track_ratio_but_point_diverges() {
247        // 50% elapsed, 52% usage → ratio 104% (within ±5) → on track,
248        // BUT point delta is 2 → point_pace = Ahead, point_label "2pts ahead".
249        let now = at(12, 0);
250        let reset = now + chrono::Duration::minutes(150);
251        let p = calc(52, Some(reset), now, FIVE_H, DEFAULT_TOLERANCE);
252        assert_eq!(p.ratio_pace, Pace::OnTrack);
253        assert_eq!(p.ratio_label, "on track");
254        assert_eq!(p.point_pace, Pace::Ahead);
255        assert_eq!(p.point_label, "2pts ahead");
256    }
257
258    #[test]
259    fn ratio_clamps_at_999() {
260        // 1% elapsed, 60% usage → pacing_x100 = 6000, dev = 5900 → clamped to 999.
261        let now = at(12, 0);
262        let reset = now + chrono::Duration::minutes(297); // ~99% remaining → 1% elapsed
263        let p = calc(60, Some(reset), now, FIVE_H, 5);
264        assert_eq!(p.elapsed_pct, 1);
265        assert_eq!(p.ratio_label, "999% ahead");
266    }
267
268    #[test]
269    fn elapsed_zero_skips_ratio() {
270        // 0% elapsed → ratio code is skipped; ratio defaults to on track.
271        let now = at(12, 0);
272        let reset = now + FIVE_H; // full window remains
273        let p = calc(20, Some(reset), now, FIVE_H, 5);
274        assert_eq!(p.elapsed_pct, 0);
275        assert_eq!(p.ratio_pace, Pace::OnTrack);
276        // But point math still runs: delta = 20.
277        assert_eq!(p.delta, 20);
278        assert_eq!(p.point_pace, Pace::Ahead);
279    }
280
281    #[test]
282    fn severity_boundaries_match_claudebar() {
283        // claudebar: <= -10 green, -10..=0 yellow, 1..9 orange, >= 10 red
284        assert_eq!(pace_severity(-100), PaceSeverity::Low);
285        assert_eq!(pace_severity(-10), PaceSeverity::Mid); // -10 is in -10..=0 band
286        assert_eq!(pace_severity(-1), PaceSeverity::Mid);
287        assert_eq!(pace_severity(0), PaceSeverity::Mid);
288        assert_eq!(pace_severity(1), PaceSeverity::High);
289        assert_eq!(pace_severity(9), PaceSeverity::High);
290        assert_eq!(pace_severity(10), PaceSeverity::Critical);
291        assert_eq!(pace_severity(100), PaceSeverity::Critical);
292    }
293
294    #[test]
295    fn neutral_constructor_matches_default_state() {
296        let n = Pacing::neutral();
297        assert_eq!(n.elapsed_pct, 0);
298        assert_eq!(n.delta, 0);
299        assert_eq!(n.ratio_pace, Pace::OnTrack);
300        assert_eq!(n.point_pace, Pace::OnTrack);
301        assert_eq!(n.ratio_label, "on track");
302        assert_eq!(n.point_label, "on track");
303    }
304}