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)]
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 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 let now = at(12, 0);
209 let reset = now + chrono::Duration::minutes(150); 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 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 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 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 let now = at(12, 0);
262 let reset = now + chrono::Duration::minutes(297); 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 let now = at(12, 0);
272 let reset = now + FIVE_H; 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 assert_eq!(p.delta, 20);
278 assert_eq!(p.point_pace, Pace::Ahead);
279 }
280
281 #[test]
282 fn severity_boundaries_match_claudebar() {
283 assert_eq!(pace_severity(-100), PaceSeverity::Low);
285 assert_eq!(pace_severity(-10), PaceSeverity::Mid); 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}