Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .github/screenshots/etd.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ The Claude Code tray icon uses the same warm usage colors as the Claude bar. The

Hovering over a tray icon shows the usage values for that model.

### Estimated Time to Depletion (ETD)

Enable **Show ETD** from the right-click menu (off by default) to see how long until you run out at your current pace. When a usage bar is on track to deplete before its window resets, the cell appends the estimate after the remaining time — for example `90% · 1d rem · 13h ETD` means roughly one day until the weekly window resets, but at the current burn rate you would run out in about 13 hours. Cells that are pacing safely show nothing extra.

![ETD](.github/screenshots/etd.png)

Inspired by [issue #21](https://github.com/CodeZeno/Claude-Code-Usage-Monitor/issues/21).

## Diagnostics

If you need to troubleshoot startup or visibility issues, run:
Expand Down
3 changes: 3 additions & 0 deletions src/localization/dutch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,8 @@ pub(super) const STRINGS: Strings = Strings {
codex_token_expired_title: "Codex-authenticatiefout",
codex_token_expired_body: "Voer 'codex' uit in een terminal en volg de aanmeldstappen. Ververs of herstart de app daarna.",
codex_window_title: "Codex-gebruiksmonitor",
show_etd: "Toon ETD",
etd_suffix: "ETD",
rem: "rem",
second_suffix: "s",
};
3 changes: 3 additions & 0 deletions src/localization/english.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,8 @@ pub(super) const STRINGS: Strings = Strings {
codex_token_expired_title: "Codex Auth Error",
codex_token_expired_body: "Run 'codex' in a terminal and follow the sign-in prompts. After that, refresh or restart this app.",
codex_window_title: "Codex Usage Monitor",
show_etd: "Show ETD",
etd_suffix: "ETD",
rem: "rem",
second_suffix: "s",
};
3 changes: 3 additions & 0 deletions src/localization/french.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,8 @@ pub(super) const STRINGS: Strings = Strings {
codex_token_expired_title: "Erreur d'authentification Codex",
codex_token_expired_body: "Executez 'codex' dans un terminal et suivez les instructions de connexion. Ensuite, actualisez ou redemarrez cette application.",
codex_window_title: "Moniteur d'utilisation Codex",
show_etd: "Afficher l'ETD",
etd_suffix: "ETD",
rem: "rem",
second_suffix: "s",
};
3 changes: 3 additions & 0 deletions src/localization/german.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,8 @@ pub(super) const STRINGS: Strings = Strings {
codex_token_expired_title: "Codex-Authentifizierungsfehler",
codex_token_expired_body: "Fuhren Sie 'codex' in einem Terminal aus und folgen Sie den Anmeldeanweisungen. Aktualisieren oder starten Sie diese App anschliessend neu.",
codex_window_title: "Codex-Nutzungsmonitor",
show_etd: "ETD anzeigen",
etd_suffix: "ETD",
rem: "rem",
second_suffix: "s",
};
3 changes: 3 additions & 0 deletions src/localization/japanese.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,8 @@ pub(super) const STRINGS: Strings = Strings {
codex_token_expired_title: "Codex 認証エラー",
codex_token_expired_body: "ターミナルで 'codex' を実行し、サインインの案内に従ってください。その後、このアプリを更新または再起動してください。",
codex_window_title: "Codex 使用量モニター",
show_etd: "ETD を表示",
etd_suffix: "ETD",
rem: "rem",
second_suffix: "秒",
};
3 changes: 3 additions & 0 deletions src/localization/korean.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,8 @@ pub(super) const STRINGS: Strings = Strings {
codex_token_expired_title: "Codex 인증 오류",
codex_token_expired_body: "터미널에서 'codex'를 실행하고 로그인 안내를 따르세요. 그런 다음 이 앱을 새로 고치거나 다시 시작하세요.",
codex_window_title: "Codex 사용량 모니터",
show_etd: "ETD 표시",
etd_suffix: "ETD",
rem: "rem",
second_suffix: "초",
};
3 changes: 3 additions & 0 deletions src/localization/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,9 @@ pub struct Strings {
pub codex_token_expired_title: &'static str,
pub codex_token_expired_body: &'static str,
pub codex_window_title: &'static str,
pub show_etd: &'static str,
pub etd_suffix: &'static str,
pub rem: &'static str,
}

pub fn resolve_language(language_override: Option<LanguageId>) -> LanguageId {
Expand Down
3 changes: 3 additions & 0 deletions src/localization/portuguese_brazil.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,7 @@ pub(super) const STRINGS: Strings = Strings {
codex_token_expired_title: "Erro de Autenticação do Codex",
codex_token_expired_body: "Execute 'codex' em um terminal e siga as instruções de login. Depois disso, atualize ou reinicie este aplicativo.",
codex_window_title: "Monitor de uso do Codex",
show_etd: "Mostrar ETD",
etd_suffix: "ETD",
rem: "rem",
};
3 changes: 3 additions & 0 deletions src/localization/russian.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,7 @@ pub(super) const STRINGS: Strings = Strings {
codex_token_expired_title: "Ошибка авторизации Codex",
codex_token_expired_body: "Запустите 'codex' в терминале и следуйте инструкциям для входа. После этого обновите или перезапустите приложение.",
codex_window_title: "Монитор использования Codex",
show_etd: "Показывать ETD",
etd_suffix: "ETD",
rem: "rem",
};
3 changes: 3 additions & 0 deletions src/localization/spanish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,8 @@ pub(super) const STRINGS: Strings = Strings {
codex_token_expired_title: "Error de autenticacion de Codex",
codex_token_expired_body: "Ejecuta 'codex' en una terminal y sigue las indicaciones de inicio de sesion. Despues, actualiza o reinicia esta aplicacion.",
codex_window_title: "Monitor de uso de Codex",
show_etd: "Mostrar ETD",
etd_suffix: "ETD",
rem: "rem",
second_suffix: "s",
};
3 changes: 3 additions & 0 deletions src/localization/traditional_chinese.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,8 @@ pub(super) const STRINGS: Strings = Strings {
codex_token_expired_title: "Codex 驗證錯誤",
codex_token_expired_body: "請在終端機中執行 'codex',並依照登入提示操作。完成後,請重新整理或重新啟動此應用程式。",
codex_window_title: "Codex 使用量監控",
show_etd: "顯示 ETD",
etd_suffix: "ETD",
rem: "rem",
second_suffix: "秒",
};
135 changes: 135 additions & 0 deletions src/poller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1097,3 +1097,138 @@ 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<u64> {
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)
}

/// The trailing " rem · 45m ETD" segment for a usage cell, or `None` when the
/// section is not on pace to deplete before reset. The leading `rem` labels the
/// preceding countdown (which `format_line` left unlabeled) so the remaining
/// time and the depletion estimate are distinguishable. Reuses the same coarse,
/// single-unit duration format as the countdown.
pub fn etd_suffix(section: &UsageSection, window_secs: u64, strings: Strings) -> Option<String> {
let reset = section.resets_at?;
let remaining_secs = reset.duration_since(SystemTime::now()).ok()?.as_secs();
let secs = etd_secs(section.percentage, remaining_secs, window_secs)?;
let dur = format_countdown_from_secs(secs, strings);
Some(format!(
" {} \u{00b7} {dur} {}",
strings.rem, strings.etd_suffix
))
}

#[cfg(test)]
mod tests {
use super::*;
use crate::localization::LanguageId;
use std::time::Duration;

fn section(pct: f64, remaining: Duration) -> UsageSection {
UsageSection {
percentage: pct,
resets_at: Some(SystemTime::now() + remaining),
}
}

#[test]
fn etd_suffix_present_when_at_risk() {
// 60% used, 1h remaining of a 2h window → at risk.
let s = section(60.0, Duration::from_secs(3600));
let out = etd_suffix(&s, 2 * 3600, LanguageId::English.strings());
let out = out.expect("expected a suffix when at risk");
assert!(out.contains("ETD"), "suffix was: {out}");
// Labels the preceding countdown with "rem", then the ETD segment.
assert!(out.starts_with(" rem \u{00b7} "), "suffix was: {out}");
}

#[test]
fn etd_suffix_absent_when_safe() {
// 10% used, 4h remaining of a 5h window → safe.
let s = section(10.0, Duration::from_secs(4 * 3600));
assert_eq!(etd_suffix(&s, 5 * 3600, LanguageId::English.strings()), None);
}

#[test]
fn etd_suffix_absent_without_reset() {
let s = UsageSection { percentage: 60.0, resets_at: None };
assert_eq!(etd_suffix(&s, 2 * 3600, LanguageId::English.strings()), None);
}

#[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}"
);
}
}
}
}
Loading