diff --git a/docs/superpowers/plans/2026-06-19-etd.md b/docs/superpowers/plans/2026-06-19-etd.md new file mode 100644 index 0000000..a1ec664 --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-etd.md @@ -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` (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 { + 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` 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 { + 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 ` · 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 `… · 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). + +![ETD](.github/screenshots/etd.png) +``` + +- [ ] **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` — same signature in Task 1 def and Task 3 call. ✓ +- `etd_suffix(section: &UsageSection, window_secs: u64, strings: Strings) -> Option` — 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. ✓ diff --git a/docs/superpowers/specs/2026-06-19-etd-design.md b/docs/superpowers/specs/2026-06-19-etd-design.md new file mode 100644 index 0000000..c4685f8 --- /dev/null +++ b/docs/superpowers/specs/2026-06-19-etd-design.md @@ -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 { + 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` 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.