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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user