mirror of
https://github.com/CodeZeno/Claude-Code-Usage-Monitor.git
synced 2026-06-20 10:19:27 +08:00
docs: ETD design spec and implementation plan
This commit is contained in:
parent
7af314996d
commit
e2d5298466
596
docs/superpowers/plans/2026-06-19-etd.md
Normal file
596
docs/superpowers/plans/2026-06-19-etd.md
Normal file
@ -0,0 +1,596 @@
|
||||
# Estimated Time to Depletion (ETD) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Append an "estimated time to depletion" suffix (e.g. `· 45m ETD`) to each usage cell when the user is on pace to deplete a quota before its window resets.
|
||||
|
||||
**Architecture:** A pure `etd_secs()` math function plus an `etd_suffix()` formatter in `src/poller.rs`, fed by the data already available at the cell-formatting site. A `show_etd` setting is plumbed through `src/window.rs` exactly like the existing `show_codex` bool, exposed as a Settings-submenu checkbox (default OFF), and consumed in `refresh_usage_texts`. Two new localized strings.
|
||||
|
||||
**Tech Stack:** Rust 2021, `serde`/`serde_json` for settings, the `windows` crate for the Win32 menu, a flat `Strings` struct of `&'static str` for localization.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Branch off clean `main` (v1.4.3); do **not** depend on the detailed-remaining or pace-indicator branches.
|
||||
- Menu ID for ETD is exactly `74` (avoids the 70–73 used by the other branches).
|
||||
- ETD window constants live in `src/poller.rs` (NOT `src/window.rs`).
|
||||
- ETD duration uses the existing `format_countdown_from_secs` (coarse, single-unit). Do not add a multi-unit formatter.
|
||||
- `show_etd` defaults to `false` (OFF).
|
||||
- Do not reference any upstream issue in commit messages or PR text.
|
||||
- Middle-dot separator is the Unicode escape `\u{00b7}` (matches `format_line`).
|
||||
- Each task ends green: `cargo build` succeeds, and where tests exist, `cargo test` passes.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: ETD core math + first test module
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/poller.rs` (append at end of file, after `app_is_past_reset`, currently ending line 1099)
|
||||
- Test: `src/poller.rs` (inline `#[cfg(test)] mod tests`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `pub const SESSION_WINDOW_SECS: u64`, `pub const WEEKLY_WINDOW_SECS: u64`, and `fn etd_secs(actual_pct: f64, remaining_secs: u64, window_secs: u64) -> Option<u64>` (module-private; tested inline).
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to the end of `src/poller.rs`:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn etd_none_when_on_safe_pace() {
|
||||
// 50% used, 1h elapsed of a 5h window (4h remaining).
|
||||
// Project ~1h more to full → total ~2h « 4h remaining → safe.
|
||||
assert_eq!(etd_secs(50.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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cargo test --lib etd_ 2>&1 | tail -20` (or `cargo test etd_`)
|
||||
Expected: FAIL — `cannot find function `etd_secs` in this scope` (compile error).
|
||||
|
||||
- [ ] **Step 3: Write the constants and `etd_secs`**
|
||||
|
||||
Append to `src/poller.rs` *above* the `#[cfg(test)]` module:
|
||||
|
||||
```rust
|
||||
/// 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)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cargo test etd_ 2>&1 | tail -20`
|
||||
Expected: PASS — 4 tests pass. (A `dead_code` warning on `SESSION_WINDOW_SECS`/`WEEKLY_WINDOW_SECS`/`etd_secs` is expected until later tasks consume them; it is not an error.)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/poller.rs
|
||||
git commit -m "feat: add ETD projection math with unit tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Localization strings
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/localization/mod.rs:179` (add two fields to `Strings`, after `codex_window_title`)
|
||||
- Modify all 10 language files: `src/localization/english.rs`, `dutch.rs`, `spanish.rs`, `french.rs`, `german.rs`, `japanese.rs`, `korean.rs`, `traditional_chinese.rs`, `russian.rs`, `portuguese_brazil.rs`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `Strings::show_etd` and `Strings::etd_suffix` (both `&'static str`), available to later tasks.
|
||||
|
||||
- [ ] **Step 1: Add the fields to the `Strings` struct**
|
||||
|
||||
In `src/localization/mod.rs`, the `Strings` struct ends at line 180 with `pub codex_window_title: &'static str,` on line 179. Add immediately after it:
|
||||
|
||||
```rust
|
||||
pub show_etd: &'static str,
|
||||
pub etd_suffix: &'static str,
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify it fails to compile**
|
||||
|
||||
Run: `cargo build 2>&1 | tail -20`
|
||||
Expected: FAIL — `missing field `show_etd` in initializer of `Strings`` (one error per language file).
|
||||
|
||||
- [ ] **Step 3: Fill in every language file**
|
||||
|
||||
Add these two lines to the `Strings { ... }` initializer in each file (place them next to the existing `codex_window_title` line). Use these values:
|
||||
|
||||
`english.rs`:
|
||||
```rust
|
||||
show_etd: "Show ETD",
|
||||
etd_suffix: "ETD",
|
||||
```
|
||||
`dutch.rs`:
|
||||
```rust
|
||||
show_etd: "Toon ETD",
|
||||
etd_suffix: "ETD",
|
||||
```
|
||||
`spanish.rs`:
|
||||
```rust
|
||||
show_etd: "Mostrar ETD",
|
||||
etd_suffix: "ETD",
|
||||
```
|
||||
`french.rs`:
|
||||
```rust
|
||||
show_etd: "Afficher l'ETD",
|
||||
etd_suffix: "ETD",
|
||||
```
|
||||
`german.rs`:
|
||||
```rust
|
||||
show_etd: "ETD anzeigen",
|
||||
etd_suffix: "ETD",
|
||||
```
|
||||
`japanese.rs`:
|
||||
```rust
|
||||
show_etd: "ETD を表示",
|
||||
etd_suffix: "ETD",
|
||||
```
|
||||
`korean.rs`:
|
||||
```rust
|
||||
show_etd: "ETD 표시",
|
||||
etd_suffix: "ETD",
|
||||
```
|
||||
`traditional_chinese.rs`:
|
||||
```rust
|
||||
show_etd: "顯示 ETD",
|
||||
etd_suffix: "ETD",
|
||||
```
|
||||
`russian.rs`:
|
||||
```rust
|
||||
show_etd: "Показывать ETD",
|
||||
etd_suffix: "ETD",
|
||||
```
|
||||
`portuguese_brazil.rs`:
|
||||
```rust
|
||||
show_etd: "Mostrar ETD",
|
||||
etd_suffix: "ETD",
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify it compiles**
|
||||
|
||||
Run: `cargo build 2>&1 | tail -20`
|
||||
Expected: SUCCESS (warnings about unused `show_etd`/`etd_suffix` are fine until later tasks).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/localization/
|
||||
git commit -m "feat: add ETD localization strings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: ETD suffix formatter
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/poller.rs` (add `etd_suffix` next to `etd_secs`)
|
||||
- Test: `src/poller.rs` (extend the `tests` module)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `etd_secs` (Task 1), `format_countdown_from_secs` (existing private fn), `Strings::etd_suffix` (Task 2).
|
||||
- Produces: `pub fn etd_suffix(section: &UsageSection, window_secs: u64, strings: Strings) -> Option<String>` returning a string like `" \u{00b7} 45m ETD"`, or `None` when not at risk.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add inside `mod tests` in `src/poller.rs`:
|
||||
|
||||
```rust
|
||||
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}");
|
||||
assert!(out.starts_with(" \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);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cargo test etd_suffix 2>&1 | tail -20`
|
||||
Expected: FAIL — `cannot find function `etd_suffix``.
|
||||
|
||||
- [ ] **Step 3: Implement `etd_suffix`**
|
||||
|
||||
In `src/poller.rs`, immediately after `etd_secs`, add:
|
||||
|
||||
```rust
|
||||
/// The trailing " · 45m ETD" segment for a usage cell, or `None` when the
|
||||
/// section is not on pace to deplete before reset. 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.etd_suffix))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cargo test etd 2>&1 | tail -20`
|
||||
Expected: PASS — all ETD tests pass (`etd_secs` is now used, so its dead-code warning clears; `SESSION_WINDOW_SECS`/`WEEKLY_WINDOW_SECS` may still warn until Task 5).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/poller.rs
|
||||
git commit -m "feat: add ETD suffix formatter"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `show_etd` setting plumbing
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/window.rs` — `AppState` struct (after line 67 `show_codex: bool,`), `SettingsFile` (after line 217), `SettingsFile::default` (after line 229), `save_state_settings` (after line 284), `AppState` init (after line 1017).
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `AppState.show_etd: bool` and `SettingsFile.show_etd: bool`, persisted to `settings.json`, defaulting to `false`. Consumed by Task 5.
|
||||
|
||||
- [ ] **Step 1: Add the `AppState` field**
|
||||
|
||||
In `src/window.rs`, after line 67 (` show_codex: bool,`) add:
|
||||
|
||||
```rust
|
||||
show_etd: bool,
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the `SettingsFile` field**
|
||||
|
||||
After line 217 (` show_codex: bool,` inside `struct SettingsFile`) add:
|
||||
|
||||
```rust
|
||||
#[serde(default)]
|
||||
show_etd: bool,
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add to `SettingsFile::default`**
|
||||
|
||||
After line 229 (` show_codex: false,`) add:
|
||||
|
||||
```rust
|
||||
show_etd: false,
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add to `save_state_settings`**
|
||||
|
||||
After line 284 (` show_codex: s.show_codex,`) add:
|
||||
|
||||
```rust
|
||||
show_etd: s.show_etd,
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add to the `AppState` initializer**
|
||||
|
||||
After line 1017 (` show_codex: settings.show_codex,`) add:
|
||||
|
||||
```rust
|
||||
show_etd: settings.show_etd,
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify it compiles**
|
||||
|
||||
Run: `cargo build 2>&1 | tail -20`
|
||||
Expected: SUCCESS (an unused-field warning on `show_etd` is expected until Task 5 reads it).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/window.rs
|
||||
git commit -m "feat: persist show_etd setting"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Menu toggle, command handler, and cell wiring
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/window.rs` — menu ID const (after line 125), `show_context_menu` destructure (lines 2334–2335 and the `None` fallback 2357–2359), Settings-submenu item (after the reset-position item, line 2463), `WM_COMMAND` handler (after the `IDM_MODEL_*` arm, line 2254), and `refresh_usage_texts` (lines 422–436).
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `poller::etd_suffix`, `poller::SESSION_WINDOW_SECS`, `poller::WEEKLY_WINDOW_SECS` (Tasks 1+3), `AppState.show_etd` (Task 4), `Strings::show_etd` (Task 2).
|
||||
|
||||
- [ ] **Step 1: Add the menu ID constant**
|
||||
|
||||
In `src/window.rs`, after line 125 (`const IDM_MODEL_CODEX: u16 = 61;`) add:
|
||||
|
||||
```rust
|
||||
const IDM_SHOW_ETD: u16 = 74;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Append ETD to the cell text in `refresh_usage_texts`**
|
||||
|
||||
Replace the body block at lines 422–436 (the two `if let Some(...)` blocks) with:
|
||||
|
||||
```rust
|
||||
if let Some(claude_code) = data.claude_code.as_ref() {
|
||||
state.session_text = poller::format_line(&claude_code.session, strings);
|
||||
state.weekly_text = poller::format_line(&claude_code.weekly, strings);
|
||||
if state.show_etd {
|
||||
if let Some(s) =
|
||||
poller::etd_suffix(&claude_code.session, poller::SESSION_WINDOW_SECS, strings)
|
||||
{
|
||||
state.session_text.push_str(&s);
|
||||
}
|
||||
if let Some(s) =
|
||||
poller::etd_suffix(&claude_code.weekly, poller::WEEKLY_WINDOW_SECS, strings)
|
||||
{
|
||||
state.weekly_text.push_str(&s);
|
||||
}
|
||||
}
|
||||
} else if state.show_claude_code {
|
||||
state.session_text = "!".to_string();
|
||||
state.weekly_text = "!".to_string();
|
||||
}
|
||||
|
||||
if let Some(codex) = data.codex.as_ref() {
|
||||
state.codex_session_text = poller::format_line(&codex.session, strings);
|
||||
state.codex_weekly_text = poller::format_line(&codex.weekly, strings);
|
||||
if state.show_etd {
|
||||
if let Some(s) =
|
||||
poller::etd_suffix(&codex.session, poller::SESSION_WINDOW_SECS, strings)
|
||||
{
|
||||
state.codex_session_text.push_str(&s);
|
||||
}
|
||||
if let Some(s) =
|
||||
poller::etd_suffix(&codex.weekly, poller::WEEKLY_WINDOW_SECS, strings)
|
||||
{
|
||||
state.codex_weekly_text.push_str(&s);
|
||||
}
|
||||
}
|
||||
} else if state.show_codex {
|
||||
state.codex_session_text = "!".to_string();
|
||||
state.codex_weekly_text = "!".to_string();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Surface `show_etd` in `show_context_menu`**
|
||||
|
||||
(a) In the destructure tuple, after line 2335 (` show_codex,`) add:
|
||||
|
||||
```rust
|
||||
show_etd,
|
||||
```
|
||||
|
||||
(b) In the `Some(s) => ( ... )` arm, after line 2348 (` s.show_codex,`) add:
|
||||
|
||||
```rust
|
||||
s.show_etd,
|
||||
```
|
||||
|
||||
(c) In the `None => ( ... )` fallback, after line 2359 (` false,` — the `show_codex` fallback) add another:
|
||||
|
||||
```rust
|
||||
false,
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the Settings-submenu checkbox**
|
||||
|
||||
In `show_context_menu`, after the reset-position `AppendMenuW` block (ends line 2463) add:
|
||||
|
||||
```rust
|
||||
let etd_str = native_interop::wide_str(strings.show_etd);
|
||||
let etd_flags = if show_etd {
|
||||
MF_CHECKED
|
||||
} else {
|
||||
MENU_ITEM_FLAGS(0)
|
||||
};
|
||||
let _ = AppendMenuW(
|
||||
settings_menu,
|
||||
etd_flags,
|
||||
IDM_SHOW_ETD as usize,
|
||||
PCWSTR::from_raw(etd_str.as_ptr()),
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add the `WM_COMMAND` handler arm**
|
||||
|
||||
In the `WM_COMMAND` match, after the `IDM_MODEL_CLAUDE_CODE | IDM_MODEL_CODEX => { ... }` arm (ends line 2254) add:
|
||||
|
||||
```rust
|
||||
IDM_SHOW_ETD => {
|
||||
{
|
||||
let mut state = lock_state();
|
||||
if let Some(s) = state.as_mut() {
|
||||
s.show_etd = !s.show_etd;
|
||||
refresh_usage_texts(s);
|
||||
}
|
||||
}
|
||||
save_state_settings();
|
||||
render_layered();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify it compiles cleanly**
|
||||
|
||||
Run: `cargo build 2>&1 | tail -20`
|
||||
Expected: SUCCESS, no `dead_code` / unused warnings for any ETD symbol (all are now consumed).
|
||||
|
||||
- [ ] **Step 7: Run the full test suite**
|
||||
|
||||
Run: `cargo test 2>&1 | tail -20`
|
||||
Expected: PASS — all ETD tests green.
|
||||
|
||||
- [ ] **Step 8: Manual verification**
|
||||
|
||||
Run: `cargo run` (or build a release and launch). Then:
|
||||
1. Right-click the widget → Settings. Confirm a `Show ETD` item exists, unchecked (default OFF).
|
||||
2. Click it → it becomes checked.
|
||||
3. With a Claude/Codex cell currently over pace, confirm the cell text gains a ` · <dur> ETD` suffix; for cells on safe pace, confirm no suffix.
|
||||
4. Uncheck `Show ETD` → suffixes disappear.
|
||||
5. Restart the app → the toggle state persisted (read back from `settings.json`).
|
||||
|
||||
Document the observed result (pass/fail per step) in the task notes.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add src/window.rs
|
||||
git commit -m "feat: add Show ETD toggle and wire ETD into usage cells"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: README + screenshot
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
- Create: `.github/screenshots/etd.png` (a captured screenshot of an at-risk cell showing the suffix)
|
||||
|
||||
**Interfaces:** none (docs only).
|
||||
|
||||
- [ ] **Step 1: Capture a screenshot**
|
||||
|
||||
With `Show ETD` enabled and a cell at risk, capture the widget showing `… · <dur> ETD` and save it to `.github/screenshots/etd.png`. (Match the dimensions/style of the existing screenshots in that folder.)
|
||||
|
||||
- [ ] **Step 2: Add a README paragraph**
|
||||
|
||||
Find the existing screenshots/features section in `README.md` (where the other feature screenshots are referenced) and add, in the same style:
|
||||
|
||||
```markdown
|
||||
### Estimated Time to Depletion (ETD)
|
||||
|
||||
When you're on pace to use up a quota before its window resets, each affected
|
||||
usage cell shows an estimate of how long that will take — e.g. `50% · 2h · 45m ETD`.
|
||||
Enable it from the tray menu under **Settings → Show ETD** (off by default).
|
||||
|
||||

|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify the link resolves**
|
||||
|
||||
Run: `test -f .github/screenshots/etd.png && echo OK`
|
||||
Expected: `OK`.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add README.md .github/screenshots/etd.png
|
||||
git commit -m "docs: document the ETD indicator"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**1. Spec coverage:**
|
||||
- §4 "When shown / format / scope / setting" → Tasks 1, 3, 5. ✓
|
||||
- §5 computation + invariant → Task 1 (incl. invariant test). ✓
|
||||
- §6 architecture (poller helpers, window plumbing, localization) → Tasks 1–5. ✓
|
||||
- §7 changes-by-file → every listed file appears in a task (poller, mod.rs, 10 lang files, window.rs, README). ✓
|
||||
- §8 testing (first `#[cfg(test)]`, 5+ tests) → Tasks 1 & 3. ✓
|
||||
- Default OFF → Task 4 Step 3 + Task 5 Step 4 flags. ✓
|
||||
- Independence/compatibility constraints (ID 74, constants in poller, coarse format) → Global Constraints + Tasks 1, 5. ✓
|
||||
|
||||
**2. Placeholder scan:** No "TBD"/"add error handling"/"similar to" — all code shown inline. The only deferred artifact is the binary screenshot in Task 6, which is captured manually by definition. ✓
|
||||
|
||||
**3. Type consistency:**
|
||||
- `etd_secs(actual_pct: f64, remaining_secs: u64, window_secs: u64) -> Option<u64>` — same signature in Task 1 def and Task 3 call. ✓
|
||||
- `etd_suffix(section: &UsageSection, window_secs: u64, strings: Strings) -> Option<String>` — Task 3 def matches Task 5 call sites (`poller::etd_suffix(&claude_code.session, poller::SESSION_WINDOW_SECS, strings)`). ✓
|
||||
- `SESSION_WINDOW_SECS` / `WEEKLY_WINDOW_SECS` are `pub` (Task 1) so Task 5's `poller::` references resolve. ✓
|
||||
- `Strings::show_etd` / `Strings::etd_suffix` defined in Task 2, used in Tasks 3 & 5. ✓
|
||||
- `IDM_SHOW_ETD = 74` defined Task 5 Step 1, used Steps 4 & 5. ✓
|
||||
122
docs/superpowers/specs/2026-06-19-etd-design.md
Normal file
122
docs/superpowers/specs/2026-06-19-etd-design.md
Normal file
@ -0,0 +1,122 @@
|
||||
# Estimated Time to Depletion (ETD) — Design
|
||||
|
||||
- **Date:** 2026-06-19
|
||||
- **Branch:** `feat/etd`, branched off **clean `main`** (v1.4.3)
|
||||
- **Status:** Approved (design); plan written
|
||||
- **Independence:** Self-contained. Does **not** depend on the "Show detailed remaining time" or pace-indicator ("colored") branches, and is mergeable alongside them with only trivial textual conflicts.
|
||||
- **PR policy:** Do not reference any upstream issue in PR title, description, or commit messages.
|
||||
|
||||
## 1. Problem
|
||||
|
||||
Users have to mentally extrapolate whether they will exhaust their session/weekly quota before the rolling window resets. ETD answers that quantitatively: "if you keep this rate, you run out in ~45m."
|
||||
|
||||
## 2. Goal
|
||||
|
||||
When the user is on pace to deplete a quota *before* its window resets, append a short estimate to that cell's usage text. When on safe pace, append nothing.
|
||||
|
||||
## 3. Independence & compatibility
|
||||
|
||||
This feature branches off clean `main`, where:
|
||||
|
||||
- Cell text is built by `poller::format_line(section, strings)` → `"50% · 2h"` (percentage + **single-unit** countdown).
|
||||
- There are **no** window-length constants, no `expected_pace_pct`, and no `show_detailed_remaining` / `show_pace_indicator` settings — those all live only on the author's other two feature branches.
|
||||
|
||||
Consequences, chosen to keep ETD independent yet compatible:
|
||||
|
||||
| Concern | Decision |
|
||||
|---|---|
|
||||
| Duration precision | Reuse `main`'s native **coarse single-unit** countdown formatter (`format_countdown_from_secs`). No dependency on the detailed-remaining toggle. |
|
||||
| Window constants | ETD defines its own `SESSION_WINDOW_SECS` / `WEEKLY_WINDOW_SECS` in **`poller.rs`** (the pace branch defines same-named constants in `window.rs` — different modules, so no symbol clash on merge). |
|
||||
| Menu ID | `IDM_SHOW_ETD = 74` (the other branches use 70–73; no numeric clash). |
|
||||
| Struct/localization fields | Added additively; a three-way merge yields only trivial field-list conflicts. |
|
||||
|
||||
## 4. User-facing behavior
|
||||
|
||||
| Aspect | Behavior |
|
||||
|---|---|
|
||||
| **When shown** | Only when projected depletion is *before* the window resets (the at-risk case). |
|
||||
| **What's shown** | A suffix appended to the cell's existing text, after a middle-dot separator: on base `main` an at-risk cell reads **`50% · 2h · 45m ETD`**. |
|
||||
| **Format** | The ETD duration uses the same coarse single-unit formatter as the countdown (`45m`, `2h`, `3d`). The trailing word comes from a localized `etd_suffix` string (English `"ETD"`). |
|
||||
| **Scope** | All four cells: Claude session, Claude weekly, Codex session, Codex weekly. |
|
||||
| **Setting** | New `Show ETD` checkbox in the Settings submenu. **Default OFF** (opt-in). |
|
||||
| **Compatibility note** | If the detailed-remaining branch is also merged, the left segment becomes richer (e.g. `50% · 4h 32m`) automatically; the ETD suffix is unaffected. |
|
||||
|
||||
## 5. Computation
|
||||
|
||||
Pure function over three numbers already available at the formatting site:
|
||||
|
||||
```rust
|
||||
/// Estimated seconds to full depletion at the current burn rate
|
||||
/// (quota-so-far / time-so-far). `None` unless depletion lands before reset.
|
||||
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)
|
||||
}
|
||||
```
|
||||
|
||||
### Invariant
|
||||
|
||||
`etd_secs` returns `Some` **iff** `actual_pct > 100 * elapsed / window` — i.e. the current burn rate exceeds steady-state pace. (Because `remaining` is an integer, the `as u64` truncation does not move the boundary versus the exact float comparison.) This is the "at-risk" rule, and a free correctness check.
|
||||
|
||||
### Edge cases
|
||||
|
||||
| Condition | Behavior |
|
||||
|---|---|
|
||||
| `show_etd` off | Helper not called; nothing appended. |
|
||||
| `resets_at` is `None` | No reset timestamp → nothing appended. |
|
||||
| `actual_pct <= 0` or `>= 100` | `None` → nothing appended. |
|
||||
| `elapsed_secs == 0` (just started) | `None` → nothing appended. |
|
||||
| Not at risk (`secs >= remaining_secs`) | `None` → nothing appended. |
|
||||
|
||||
## 6. Architecture
|
||||
|
||||
- **`poller.rs`** gains: `SESSION_WINDOW_SECS` / `WEEKLY_WINDOW_SECS` (pub), private `etd_secs`, and pub `etd_suffix(section, window_secs, strings) -> Option<String>` returning `" · 45m ETD"`. `etd_suffix` reuses the existing private `format_countdown_from_secs`. A `#[cfg(test)] mod tests` (the repo's first) covers `etd_secs`.
|
||||
- **`window.rs`** gains: the `show_etd` bool plumbed exactly like the existing `show_codex` / `widget_visible` (SettingsFile + Default + save_state_settings + AppState + AppState init), `IDM_SHOW_ETD = 74`, a Settings-submenu checkbox, a `WM_COMMAND` arm, and append calls in `refresh_usage_texts`.
|
||||
- **`localization/`** gains two `Strings` fields — `show_etd` (menu label) and `etd_suffix` (the trailing word) — filled in all 10 language files.
|
||||
|
||||
### Rejected alternatives
|
||||
|
||||
- **Change `format_line`'s signature** to take `window_secs`/`show_etd`. Rejected: widens blast radius and couples the window-agnostic formatter to ETD; appending via `etd_suffix` at the 4 call sites is more isolated.
|
||||
- **Reuse the pace branch's constants/helpers.** Rejected: that is exactly the dependency the independence requirement forbids.
|
||||
|
||||
## 7. Changes by file
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/poller.rs` | `SESSION_WINDOW_SECS`, `WEEKLY_WINDOW_SECS`, `etd_secs`, `etd_suffix`, `#[cfg(test)] mod tests`. |
|
||||
| `src/localization/mod.rs` | Two new `Strings` fields: `show_etd`, `etd_suffix`. |
|
||||
| `src/localization/{english,dutch,spanish,french,german,japanese,korean,traditional_chinese,russian,portuguese_brazil}.rs` | Fill in the two fields (10 files). |
|
||||
| `src/window.rs` | `show_etd` plumbing, `IDM_SHOW_ETD`, menu item, command handler, `refresh_usage_texts` append. |
|
||||
| `README.md` | One paragraph + one screenshot, matching the existing screenshots section. |
|
||||
|
||||
## 8. Testing (TDD)
|
||||
|
||||
The repo currently has **no tests**; this introduces the first `#[cfg(test)]` module (no Cargo changes — `cargo test` picks up inline unit tests). The pure functions are platform-independent; `cargo test` compiles the whole crate, which builds on Windows (the dev platform).
|
||||
|
||||
1. `etd_none_when_on_safe_pace`
|
||||
2. `etd_some_when_at_risk`
|
||||
3. `etd_none_at_boundaries` — 0%, 100%, elapsed=0, remaining=0, window=0
|
||||
4. `etd_invariant_matches_at_risk_rule` — property sweep, skipping the razor's-edge band to avoid float-boundary flakiness
|
||||
5. `etd_suffix_present_when_at_risk` / `etd_suffix_absent_when_safe` — build a `UsageSection` with `resets_at = now + remaining`
|
||||
|
||||
UI / Win32 menu code stays manually verified (build, run, toggle on, drive an at-risk cell, confirm the suffix appears and disappears).
|
||||
|
||||
## 9. Risks
|
||||
|
||||
- **Estimate jitter early in a window.** Suppressed by the at-risk gate; a minimum-elapsed gate is a possible future refinement (not in v1).
|
||||
- **Translation literalness.** Missing a `Strings` field in any language file is a compile error, so nothing ships untranslated; initial translations may keep `"ETD"` verbatim until refined.
|
||||
- **Default OFF → low discoverability.** Accepted; conservative first ship, flip later if desired.
|
||||
|
||||
## 10. Out of scope
|
||||
|
||||
- Configurable burn-rate smoothing window.
|
||||
- Per-message rate (would need new poller data).
|
||||
- Threshold notifications.
|
||||
- Tooltip / hover variant.
|
||||
- Absolute clock-time display.
|
||||
Loading…
x
Reference in New Issue
Block a user