feat(ui): humanize_duration handles hours, days, and weeks (#447)

Long-running sessions (multi-hour cycles, multi-day automations)
were rendering with the seconds/minutes-only formatter, so a
two-day session showed as `2880m 0s` and `/goal` status used
Rust's Debug Duration form (`188415.234s`).

`humanize_duration` now walks through w/d/h/m/s and caps the
output at two units so it stays compact in headers and
notifications:

* `45s`, `1m 12s`, `59m 59s` (existing seconds/minutes path)
* `1h`, `2h 2m`, `3h 12m` (was `192m 30s`)
* `1d`, `1d 1h`, `2d 5h` (the multi-day case from the issue)
* `1w`, `1w 1d`, `3w 2d` (long-running automation)

The two-tier rule drops sub-minute precision once you're past
the hour boundary; the goal is "is this a couple of hours or
days," not stopwatch precision.

`/goal` status now wires through this formatter so multi-day
goal-elapsed times read as `2d 3h` instead of the previous
`188415.234s` Debug form. The notification system was the
existing caller and picks up the new format automatically.

Tests:
  4 test functions in `notifications.rs` covering the four
  formatting bands (s/m, h/m, d/h, w/d) plus the boundary cases
  on each unit.
This commit is contained in:
Hunter Bown
2026-05-03 05:05:30 -05:00
parent 0b99ad1f25
commit 3625b887fa
3 changed files with 106 additions and 12 deletions
+6
View File
@@ -117,6 +117,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
without having to discover them from `docs/SUBAGENTS.md`. Adds
the long-form aliases (`builder` / `validator` / `tester`) on
`agent_assign` for parity with the alias map.
- **Multi-day duration formatting** (#447) — `humanize_duration`
now caps at two units and promotes through h/d/w boundaries.
Long-running sessions render as `2d 3h` instead of `188415s`,
and the previous "192m 30s" cycle output becomes `3h 12m`. The
`/goal` status line picks up the same formatter so multi-day
goal-elapsed times stay readable.
- **RLM tool family** (#512) — `rlm` tool cards map to
`ToolFamily::Rlm` and render `rlm`, not `swarm`. Stale "swarm"
wording cleaned out of docs / comments / tests.
+4 -1
View File
@@ -30,10 +30,13 @@ pub fn goal(app: &mut App, arg: Option<&str>) -> CommandResult {
_ => {
// Show current goal
if let Some(ref obj) = app.goal.goal_objective {
// #447: render long elapsed times as `2d 3h` rather
// than Rust's default Debug `Duration` (which produces
// `188415.234s` or similar for multi-day goals).
let elapsed = app
.goal
.goal_started_at
.map(|t| format!("{:?}", t.elapsed()))
.map(|t| crate::tui::notifications::humanize_duration(t.elapsed()))
.unwrap_or_else(|| "unknown".to_string());
let budget_str = app
.goal
+96 -11
View File
@@ -118,22 +118,67 @@ pub fn notify_done(
notify_done_to(method, in_tmux, msg, threshold, elapsed, &mut io::stdout());
}
/// Return a human-readable duration string, e.g. `"1m 12s"` or `"45s"`.
/// Return a human-readable duration string, capped at two units so
/// it stays compact in headers and notifications.
///
/// Examples:
/// * `"45s"`, `"1m"`, `"1m 12s"`
/// * `"1h"`, `"3h 12m"` (#447 — was previously `"192m"` form)
/// * `"1d"`, `"2d 5h"` (#447 — multi-day sessions/cycles)
/// * `"1w"`, `"3w 2d"` (#447 — long-running automations)
///
/// The output drops the secondary unit when it's zero, so `"1h"`
/// rather than `"1h 0m"`. Sub-minute precision is dropped at the
/// hour mark and above; the goal is "is this a couple of hours or
/// a couple of days," not stopwatch accuracy.
#[must_use]
pub fn humanize_duration(d: Duration) -> String {
const MINUTE: u64 = 60;
const HOUR: u64 = 60 * MINUTE;
const DAY: u64 = 24 * HOUR;
const WEEK: u64 = 7 * DAY;
let total = d.as_secs();
if total == 0 {
return "0s".to_string();
}
let minutes = total / 60;
let seconds = total % 60;
if minutes == 0 {
format!("{seconds}s")
} else if seconds == 0 {
format!("{minutes}m")
} else {
format!("{minutes}m {seconds}s")
if total >= WEEK {
let w = total / WEEK;
let days = (total % WEEK) / DAY;
return if days == 0 {
format!("{w}w")
} else {
format!("{w}w {days}d")
};
}
if total >= DAY {
let days = total / DAY;
let h = (total % DAY) / HOUR;
return if h == 0 {
format!("{days}d")
} else {
format!("{days}d {h}h")
};
}
if total >= HOUR {
let h = total / HOUR;
let m = (total % HOUR) / MINUTE;
return if m == 0 {
format!("{h}h")
} else {
format!("{h}h {m}m")
};
}
if total >= MINUTE {
let m = total / MINUTE;
let s = total % MINUTE;
return if s == 0 {
format!("{m}m")
} else {
format!("{m}m {s}s")
};
}
format!("{total}s")
}
#[cfg(test)]
@@ -246,11 +291,51 @@ mod tests {
}
#[test]
fn humanize_duration_formats_correctly() {
fn humanize_duration_seconds_and_minutes() {
assert_eq!(humanize_duration(Duration::from_secs(0)), "0s");
assert_eq!(humanize_duration(Duration::from_secs(45)), "45s");
assert_eq!(humanize_duration(Duration::from_secs(60)), "1m");
assert_eq!(humanize_duration(Duration::from_secs(72)), "1m 12s");
assert_eq!(humanize_duration(Duration::from_secs(3661)), "61m 1s");
// 59m 59s — still under the hour boundary.
assert_eq!(humanize_duration(Duration::from_secs(3599)), "59m 59s");
}
#[test]
fn humanize_duration_promotes_to_hours_at_one_hour() {
// 3661s = 1h 1m 1s — under the new format the seconds fall
// off; we keep just the top two units at the hour mark.
assert_eq!(humanize_duration(Duration::from_secs(3661)), "1h 1m");
assert_eq!(humanize_duration(Duration::from_secs(3600)), "1h");
assert_eq!(humanize_duration(Duration::from_secs(7200)), "2h");
assert_eq!(humanize_duration(Duration::from_secs(7320)), "2h 2m");
// 3h 12m — the previous "192m 30s" case that motivated #447.
assert_eq!(humanize_duration(Duration::from_secs(11_550)), "3h 12m");
}
#[test]
fn humanize_duration_handles_multi_day_sessions() {
// Exactly one day.
assert_eq!(humanize_duration(Duration::from_secs(86_400)), "1d");
// 1d 1h.
assert_eq!(humanize_duration(Duration::from_secs(90_000)), "1d 1h");
// 2d 5h — the two-tier rule drops minutes/seconds.
assert_eq!(
humanize_duration(Duration::from_secs(2 * 86_400 + 5 * 3600 + 17 * 60)),
"2d 5h"
);
}
#[test]
fn humanize_duration_promotes_to_weeks_after_seven_days() {
assert_eq!(humanize_duration(Duration::from_secs(604_800)), "1w");
assert_eq!(
humanize_duration(Duration::from_secs(604_800 + 86_400)),
"1w 1d"
);
// 3w 2d — long-running automation case.
assert_eq!(
humanize_duration(Duration::from_secs(3 * 604_800 + 2 * 86_400 + 17 * 3600)),
"3w 2d"
);
}
}