docs: ETD design spec and implementation plan

This commit is contained in:
Carlos Miguel C. Resurreccion 2026-06-19 16:34:23 +08:00
parent 7af314996d
commit e2d5298466
2 changed files with 718 additions and 0 deletions

View 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).
![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<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. ✓

View 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.