1use chrono::{DateTime, Utc};
14
15pub const DEFAULT_TOLERANCE: u32 = 5;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Pace {
24 Ahead,
25 OnTrack,
26 Under,
27}
28
29impl Pace {
30 pub fn glyph(self) -> &'static str {
32 match self {
33 Pace::Ahead => "↑",
34 Pace::OnTrack => "→",
35 Pace::Under => "↓",
36 }
37 }
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct Pacing {
46 pub elapsed_pct: i32,
48 pub ratio_pace: Pace,
50 pub point_pace: Pace,
52 pub delta: i32,
54 pub ratio_label: String,
56 pub point_label: String,
58}
59
60impl Pacing {
61 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
75pub 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
149pub enum PaceSeverity {
150 Low,
151 Mid,
152 High,
153 Critical,
154}
155
156impl PaceSeverity {
157 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 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 let now = at(12, 0);
225 let reset = now + chrono::Duration::minutes(150); 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 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 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 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 let now = at(12, 0);
278 let reset = now + chrono::Duration::minutes(297); 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 let now = at(12, 0);
288 let reset = now + FIVE_H; 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 assert_eq!(p.delta, 20);
294 assert_eq!(p.point_pace, Pace::Ahead);
295 }
296
297 #[test]
298 fn severity_boundaries_match_claudebar() {
299 assert_eq!(pace_severity(-100), PaceSeverity::Low);
301 assert_eq!(pace_severity(-10), PaceSeverity::Mid); 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}