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/// The variants are declared least to most severe, and the derived `Ord`
145/// follows that order — so `a.max(b)` is "whichever of the two is worse", which
146/// is how a row with two independent severity sources (a money tier and a
147/// percentage tier) picks the one to paint with.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
149pub enum PaceSeverity {
150    Low,
151    Mid,
152    High,
153    Critical,
154}
155
156impl PaceSeverity {
157    /// Stable lowercase token used by JSON/CSS-facing presentation layers.
158    pub const fn as_str(self) -> &'static str {
159        match self {
160            Self::Low => "low",
161            Self::Mid => "mid",
162            Self::High => "high",
163            Self::Critical => "critical",
164        }
165    }
166}
167
168pub fn pace_severity(delta: i32) -> PaceSeverity {
169    if delta >= 10 {
170        PaceSeverity::Critical
171    } else if delta > 0 {
172        PaceSeverity::High
173    } else if delta >= -10 {
174        PaceSeverity::Mid
175    } else {
176        PaceSeverity::Low
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use chrono::TimeZone;
184
185    fn at(h: u32, m: u32) -> DateTime<Utc> {
186        Utc.with_ymd_and_hms(2026, 5, 23, h, m, 0).unwrap()
187    }
188
189    const FIVE_H: chrono::Duration = chrono::Duration::hours(5);
190
191    #[test]
192    fn missing_reset_returns_neutral() {
193        let p = calc(50, None, at(12, 0), FIVE_H, DEFAULT_TOLERANCE);
194        assert_eq!(p, Pacing::neutral());
195    }
196
197    #[test]
198    fn zero_window_returns_neutral() {
199        let p = calc(50, Some(at(12, 0)), at(12, 0), chrono::Duration::zero(), 5);
200        assert_eq!(p, Pacing::neutral());
201    }
202
203    #[test]
204    fn elapsed_clamps_to_zero_when_future_reset_beyond_window() {
205        // Reset is 6h away but window is 5h → "remaining > total" → negative
206        // elapsed → clamped to 0.
207        let now = at(12, 0);
208        let reset = now + chrono::Duration::hours(6);
209        let p = calc(10, Some(reset), now, FIVE_H, 5);
210        assert_eq!(p.elapsed_pct, 0);
211    }
212
213    #[test]
214    fn elapsed_clamps_to_hundred_when_past_reset() {
215        let now = at(12, 0);
216        let reset = now - chrono::Duration::hours(1);
217        let p = calc(50, Some(reset), now, FIVE_H, 5);
218        assert_eq!(p.elapsed_pct, 100);
219    }
220
221    #[test]
222    fn perfectly_even_pacing_is_on_track() {
223        // 50% elapsed, 50% usage → both metrics on track.
224        let now = at(12, 0);
225        let reset = now + chrono::Duration::minutes(150); // 2.5h remain of 5h
226        let p = calc(50, Some(reset), now, FIVE_H, DEFAULT_TOLERANCE);
227        assert_eq!(p.elapsed_pct, 50);
228        assert_eq!(p.delta, 0);
229        assert_eq!(p.ratio_pace, Pace::OnTrack);
230        assert_eq!(p.point_pace, Pace::OnTrack);
231        assert_eq!(p.ratio_label, "on track");
232        assert_eq!(p.point_label, "on track");
233    }
234
235    #[test]
236    fn ahead_of_pace_above_tolerance() {
237        // 50% elapsed, 70% usage → delta 20, ratio 140% → "40% ahead".
238        let now = at(12, 0);
239        let reset = now + chrono::Duration::minutes(150);
240        let p = calc(70, Some(reset), now, FIVE_H, 5);
241        assert_eq!(p.delta, 20);
242        assert_eq!(p.point_pace, Pace::Ahead);
243        assert_eq!(p.point_label, "20pts ahead");
244        assert_eq!(p.ratio_pace, Pace::Ahead);
245        assert_eq!(p.ratio_label, "40% ahead");
246    }
247
248    #[test]
249    fn under_pace_below_tolerance() {
250        // 50% elapsed, 30% usage → delta -20, ratio 60% → "40% under".
251        let now = at(12, 0);
252        let reset = now + chrono::Duration::minutes(150);
253        let p = calc(30, Some(reset), now, FIVE_H, 5);
254        assert_eq!(p.delta, -20);
255        assert_eq!(p.point_pace, Pace::Under);
256        assert_eq!(p.point_label, "20pts under");
257        assert_eq!(p.ratio_pace, Pace::Under);
258        assert_eq!(p.ratio_label, "40% under");
259    }
260
261    #[test]
262    fn within_tolerance_band_is_on_track_ratio_but_point_diverges() {
263        // 50% elapsed, 52% usage → ratio 104% (within ±5) → on track,
264        // BUT point delta is 2 → point_pace = Ahead, point_label "2pts ahead".
265        let now = at(12, 0);
266        let reset = now + chrono::Duration::minutes(150);
267        let p = calc(52, Some(reset), now, FIVE_H, DEFAULT_TOLERANCE);
268        assert_eq!(p.ratio_pace, Pace::OnTrack);
269        assert_eq!(p.ratio_label, "on track");
270        assert_eq!(p.point_pace, Pace::Ahead);
271        assert_eq!(p.point_label, "2pts ahead");
272    }
273
274    #[test]
275    fn ratio_clamps_at_999() {
276        // 1% elapsed, 60% usage → pacing_x100 = 6000, dev = 5900 → clamped to 999.
277        let now = at(12, 0);
278        let reset = now + chrono::Duration::minutes(297); // ~99% remaining → 1% elapsed
279        let p = calc(60, Some(reset), now, FIVE_H, 5);
280        assert_eq!(p.elapsed_pct, 1);
281        assert_eq!(p.ratio_label, "999% ahead");
282    }
283
284    #[test]
285    fn elapsed_zero_skips_ratio() {
286        // 0% elapsed → ratio code is skipped; ratio defaults to on track.
287        let now = at(12, 0);
288        let reset = now + FIVE_H; // full window remains
289        let p = calc(20, Some(reset), now, FIVE_H, 5);
290        assert_eq!(p.elapsed_pct, 0);
291        assert_eq!(p.ratio_pace, Pace::OnTrack);
292        // But point math still runs: delta = 20.
293        assert_eq!(p.delta, 20);
294        assert_eq!(p.point_pace, Pace::Ahead);
295    }
296
297    #[test]
298    fn severity_boundaries_match_claudebar() {
299        // claudebar: <= -10 green, -10..=0 yellow, 1..9 orange, >= 10 red
300        assert_eq!(pace_severity(-100), PaceSeverity::Low);
301        assert_eq!(pace_severity(-10), PaceSeverity::Mid); // -10 is in -10..=0 band
302        assert_eq!(pace_severity(-1), PaceSeverity::Mid);
303        assert_eq!(pace_severity(0), PaceSeverity::Mid);
304        assert_eq!(pace_severity(1), PaceSeverity::High);
305        assert_eq!(pace_severity(9), PaceSeverity::High);
306        assert_eq!(pace_severity(10), PaceSeverity::Critical);
307        assert_eq!(pace_severity(100), PaceSeverity::Critical);
308    }
309
310    #[test]
311    fn severity_tokens_are_stable_for_external_presenters() {
312        assert_eq!(PaceSeverity::Low.as_str(), "low");
313        assert_eq!(PaceSeverity::Mid.as_str(), "mid");
314        assert_eq!(PaceSeverity::High.as_str(), "high");
315        assert_eq!(PaceSeverity::Critical.as_str(), "critical");
316    }
317
318    #[test]
319    fn neutral_constructor_matches_default_state() {
320        let n = Pacing::neutral();
321        assert_eq!(n.elapsed_pct, 0);
322        assert_eq!(n.delta, 0);
323        assert_eq!(n.ratio_pace, Pace::OnTrack);
324        assert_eq!(n.point_pace, Pace::OnTrack);
325        assert_eq!(n.ratio_label, "on track");
326        assert_eq!(n.point_label, "on track");
327    }
328}