use chrono::{DateTime, Utc};
pub const DEFAULT_TOLERANCE: u32 = 5;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pace {
Ahead,
OnTrack,
Under,
}
impl Pace {
pub fn glyph(self) -> &'static str {
match self {
Pace::Ahead => "↑",
Pace::OnTrack => "→",
Pace::Under => "↓",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pacing {
pub elapsed_pct: i32,
pub ratio_pace: Pace,
pub point_pace: Pace,
pub delta: i32,
pub ratio_label: String,
pub point_label: String,
}
impl Pacing {
pub fn neutral() -> Self {
Self {
elapsed_pct: 0,
ratio_pace: Pace::OnTrack,
point_pace: Pace::OnTrack,
delta: 0,
ratio_label: "on track".into(),
point_label: "on track".into(),
}
}
}
pub fn calc(
usage_pct: i32,
reset: Option<DateTime<Utc>>,
now: DateTime<Utc>,
window: chrono::Duration,
tolerance: u32,
) -> Pacing {
let Some(reset) = reset else {
return Pacing::neutral();
};
if window.num_seconds() <= 0 {
return Pacing::neutral();
}
let remaining = reset.signed_duration_since(now).num_seconds();
let total = window.num_seconds();
let mut elapsed_pct = (((total - remaining) * 100) / total) as i32;
elapsed_pct = elapsed_pct.clamp(0, 100);
let delta = usage_pct - elapsed_pct;
let (point_pace, point_label) = if delta > 0 {
(Pace::Ahead, format!("{delta}pts ahead"))
} else if delta < 0 {
(Pace::Under, format!("{}pts under", -delta))
} else {
(Pace::OnTrack, "on track".to_string())
};
let (ratio_pace, ratio_label) = if elapsed_pct > 0 {
let pacing_x100 = (usage_pct * 100) / elapsed_pct;
let tol = tolerance as i32;
if pacing_x100 > 100 + tol {
let dev = (pacing_x100 - 100).min(999);
(Pace::Ahead, format!("{dev}% ahead"))
} else if pacing_x100 < 100 - tol {
let dev = (100 - pacing_x100).min(999);
(Pace::Under, format!("{dev}% under"))
} else {
(Pace::OnTrack, "on track".to_string())
}
} else {
(Pace::OnTrack, "on track".to_string())
};
Pacing {
elapsed_pct,
ratio_pace,
point_pace,
delta,
ratio_label,
point_label,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaceSeverity {
Low,
Mid,
High,
Critical,
}
pub fn pace_severity(delta: i32) -> PaceSeverity {
if delta >= 10 {
PaceSeverity::Critical
} else if delta > 0 {
PaceSeverity::High
} else if delta >= -10 {
PaceSeverity::Mid
} else {
PaceSeverity::Low
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn at(h: u32, m: u32) -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 5, 23, h, m, 0).unwrap()
}
const FIVE_H: chrono::Duration = chrono::Duration::hours(5);
#[test]
fn missing_reset_returns_neutral() {
let p = calc(50, None, at(12, 0), FIVE_H, DEFAULT_TOLERANCE);
assert_eq!(p, Pacing::neutral());
}
#[test]
fn zero_window_returns_neutral() {
let p = calc(50, Some(at(12, 0)), at(12, 0), chrono::Duration::zero(), 5);
assert_eq!(p, Pacing::neutral());
}
#[test]
fn elapsed_clamps_to_zero_when_future_reset_beyond_window() {
let now = at(12, 0);
let reset = now + chrono::Duration::hours(6);
let p = calc(10, Some(reset), now, FIVE_H, 5);
assert_eq!(p.elapsed_pct, 0);
}
#[test]
fn elapsed_clamps_to_hundred_when_past_reset() {
let now = at(12, 0);
let reset = now - chrono::Duration::hours(1);
let p = calc(50, Some(reset), now, FIVE_H, 5);
assert_eq!(p.elapsed_pct, 100);
}
#[test]
fn perfectly_even_pacing_is_on_track() {
let now = at(12, 0);
let reset = now + chrono::Duration::minutes(150); let p = calc(50, Some(reset), now, FIVE_H, DEFAULT_TOLERANCE);
assert_eq!(p.elapsed_pct, 50);
assert_eq!(p.delta, 0);
assert_eq!(p.ratio_pace, Pace::OnTrack);
assert_eq!(p.point_pace, Pace::OnTrack);
assert_eq!(p.ratio_label, "on track");
assert_eq!(p.point_label, "on track");
}
#[test]
fn ahead_of_pace_above_tolerance() {
let now = at(12, 0);
let reset = now + chrono::Duration::minutes(150);
let p = calc(70, Some(reset), now, FIVE_H, 5);
assert_eq!(p.delta, 20);
assert_eq!(p.point_pace, Pace::Ahead);
assert_eq!(p.point_label, "20pts ahead");
assert_eq!(p.ratio_pace, Pace::Ahead);
assert_eq!(p.ratio_label, "40% ahead");
}
#[test]
fn under_pace_below_tolerance() {
let now = at(12, 0);
let reset = now + chrono::Duration::minutes(150);
let p = calc(30, Some(reset), now, FIVE_H, 5);
assert_eq!(p.delta, -20);
assert_eq!(p.point_pace, Pace::Under);
assert_eq!(p.point_label, "20pts under");
assert_eq!(p.ratio_pace, Pace::Under);
assert_eq!(p.ratio_label, "40% under");
}
#[test]
fn within_tolerance_band_is_on_track_ratio_but_point_diverges() {
let now = at(12, 0);
let reset = now + chrono::Duration::minutes(150);
let p = calc(52, Some(reset), now, FIVE_H, DEFAULT_TOLERANCE);
assert_eq!(p.ratio_pace, Pace::OnTrack);
assert_eq!(p.ratio_label, "on track");
assert_eq!(p.point_pace, Pace::Ahead);
assert_eq!(p.point_label, "2pts ahead");
}
#[test]
fn ratio_clamps_at_999() {
let now = at(12, 0);
let reset = now + chrono::Duration::minutes(297); let p = calc(60, Some(reset), now, FIVE_H, 5);
assert_eq!(p.elapsed_pct, 1);
assert_eq!(p.ratio_label, "999% ahead");
}
#[test]
fn elapsed_zero_skips_ratio() {
let now = at(12, 0);
let reset = now + FIVE_H; let p = calc(20, Some(reset), now, FIVE_H, 5);
assert_eq!(p.elapsed_pct, 0);
assert_eq!(p.ratio_pace, Pace::OnTrack);
assert_eq!(p.delta, 20);
assert_eq!(p.point_pace, Pace::Ahead);
}
#[test]
fn severity_boundaries_match_claudebar() {
assert_eq!(pace_severity(-100), PaceSeverity::Low);
assert_eq!(pace_severity(-10), PaceSeverity::Mid); assert_eq!(pace_severity(-1), PaceSeverity::Mid);
assert_eq!(pace_severity(0), PaceSeverity::Mid);
assert_eq!(pace_severity(1), PaceSeverity::High);
assert_eq!(pace_severity(9), PaceSeverity::High);
assert_eq!(pace_severity(10), PaceSeverity::Critical);
assert_eq!(pace_severity(100), PaceSeverity::Critical);
}
#[test]
fn neutral_constructor_matches_default_state() {
let n = Pacing::neutral();
assert_eq!(n.elapsed_pct, 0);
assert_eq!(n.delta, 0);
assert_eq!(n.ratio_pace, Pace::OnTrack);
assert_eq!(n.point_pace, Pace::OnTrack);
assert_eq!(n.ratio_label, "on track");
assert_eq!(n.point_label, "on track");
}
}