diff --git a/src/poller.rs b/src/poller.rs index 5bd02bc..baae7f9 100644 --- a/src/poller.rs +++ b/src/poller.rs @@ -1097,3 +1097,89 @@ pub fn app_is_past_reset(data: &AppUsageData) -> bool { data.claude_code.as_ref().is_some_and(is_past_reset) || data.codex.as_ref().is_some_and(is_past_reset) } + +/// Rolling-quota window lengths, in seconds (5 hours and 7 days). Kept here, +/// next to the formatting that consumes them, so the ETD feature is +/// self-contained and does not depend on constants defined elsewhere. +pub const SESSION_WINDOW_SECS: u64 = 5 * 3600; +pub const WEEKLY_WINDOW_SECS: u64 = 7 * 86400; + +/// Estimated seconds until the quota is fully consumed, assuming the current +/// burn rate (quota-so-far / time-so-far) holds. Returns `None` unless the +/// projection lands *before* the window resets — i.e. only when the user is +/// genuinely on pace to deplete early. +fn etd_secs(actual_pct: f64, remaining_secs: u64, window_secs: u64) -> Option { + if actual_pct <= 0.0 || actual_pct >= 100.0 { + return None; + } + if remaining_secs == 0 || window_secs == 0 { + return None; + } + let elapsed_secs = window_secs.saturating_sub(remaining_secs); + if elapsed_secs == 0 { + return None; + } + let secs = (100.0 - actual_pct) * (elapsed_secs as f64) / actual_pct; + if !secs.is_finite() || secs < 0.0 { + return None; + } + let secs = secs as u64; + (secs < remaining_secs).then_some(secs) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn etd_none_when_on_safe_pace() { + // 10% used, 1h elapsed of a 5h window (4h remaining): steady pace at 1h + // is 20%, so 10% is UNDER pace → would not deplete before reset. + // (50% in the first hour would be at-risk, not safe — see the invariant.) + assert_eq!(etd_secs(10.0, 4 * 3600, 5 * 3600), None); + } + + #[test] + fn etd_some_when_at_risk() { + // 60% used, 1h elapsed of a 2h window (1h remaining). + // Remaining 40% at 60%/h needs 40 min < 60 min remaining → at risk. + assert_eq!(etd_secs(60.0, 3600, 2 * 3600), Some(2400)); + } + + #[test] + fn etd_none_at_boundaries() { + assert_eq!(etd_secs(0.0, 3600, 5 * 3600), None); // nothing used + assert_eq!(etd_secs(100.0, 3600, 5 * 3600), None); // already full + assert_eq!(etd_secs(50.0, 5 * 3600, 5 * 3600), None); // elapsed = 0 + assert_eq!(etd_secs(50.0, 0, 5 * 3600), None); // no remaining + assert_eq!(etd_secs(50.0, 3600, 0), None); // no window + } + + #[test] + fn etd_invariant_matches_at_risk_rule() { + // etd_secs is Some iff burn rate exceeds steady pace: + // actual_pct > 100 * elapsed / window. + // Skip a small band around the exact boundary to avoid float flakiness. + let window = 5 * 3600u64; + for remaining in (0..=window).step_by(600) { + let elapsed = window - remaining; + for pct_x10 in 1..1000u64 { + let actual = pct_x10 as f64 / 10.0; + if elapsed == 0 || remaining == 0 { + assert_eq!(etd_secs(actual, remaining, window), None); + continue; + } + let boundary = 100.0 * elapsed as f64 / window as f64; + if (actual - boundary).abs() < 0.05 { + continue; // razor's edge — covered by explicit boundary test + } + let at_risk = actual > boundary; + assert_eq!( + etd_secs(actual, remaining, window).is_some(), + at_risk, + "actual={actual} remaining={remaining} window={window}" + ); + } + } + } +}