Format long WebUI durations with hours
Add hour-aware output to the shared duration formatter while preserving existing seconds and minutes formatting. Reuse it for the goal elapsed counter and cover both formats with a focused regression test.
Alessandro committed
Aug 12, 2026 at 00:58 UTC
d7130b8c6783dc6ea60ae35da5d8915fdef38987
3 files changed
+26
-10
plugins/_goal/tests/test_goal_plugin.py
+20
@@ -1,5 +1,8 @@
1
from __future__ import annotations
2
3
+import base64
4
+import shutil
5
+import subprocess
6
import uuid
7
from pathlib import Path
8
from types import SimpleNamespace
@@ -108,6 +111,23 @@ def test_goal_webui_uses_state_revisions_instead_of_polling():
111
assert "goalStore.refresh(true)" in refresh
112
113
114
+@pytest.mark.skipif(not shutil.which("node"), reason="node is required")
115
+def test_goal_webui_uses_shared_hour_aware_duration_formatter():
116
+ project_root = Path(__file__).resolve().parents[3]
117
+ time_utils = (project_root / "webui" / "js" / "time-utils.js").read_bytes()
118
+ module_url = "data:text/javascript;base64," + base64.b64encode(time_utils).decode("ascii")
119
+ script = f"""
120
+import {{ formatDuration }} from {module_url!r};
121
+if (formatDuration(3_782_000) !== "1h3m2s") throw new Error("hours");
122
+if (formatDuration(62_000) !== "1m2s") throw new Error("minutes");
123
+"""
124
+ subprocess.run(["node", "--input-type=module", "-e", script], check=True)
125
+
126
+ store = (project_root / "plugins" / "_goal" / "webui" / "goal-store.js").read_text()
127
+ assert 'import { formatDuration } from "/js/time-utils.js";' in store
128
+ assert "return formatDuration(this.elapsedSeconds * 1000);" in store
129
+
130
+
131
def test_goal_command_sets_pauses_resumes_and_deletes(context_id: str):
132
created = goal_command.run(_payload(context_id, "/goal Add current goal support"))
133
assert created["effects"][0]["message"] == "Goal set."
plugins/_goal/webui/goal-store.js
+2
-7
@@ -1,5 +1,6 @@
1
import { createStore } from "/js/AlpineStore.js";
2
import { callJsonApi } from "/js/api.js";
3
+import { formatDuration } from "/js/time-utils.js";
4
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
5
import {
6
toastFrontendError,
@@ -68,13 +69,7 @@ const model = {
69
},
70
71
get elapsedLabel() {
71
- const seconds = this.elapsedSeconds;
72
- const hours = Math.floor(seconds / 3600);
73
- const minutes = Math.floor((seconds % 3600) / 60);
74
- const remainingSeconds = seconds % 60;
75
- if (hours) return `${hours}h ${minutes}m`;
76
- if (minutes) return `${minutes}m ${remainingSeconds}s`;
77
- return `${remainingSeconds}s`;
72
+ return formatDuration(this.elapsedSeconds * 1000);
73
},
74
75
onMount() {
webui/js/time-utils.js
+4
-3
@@ -292,7 +292,7 @@ export function withUserTimeFormatOptions(options = {}) {
292
/**
293
* Format a duration in milliseconds to a human-readable string
294
* @param {number} durationMs - Duration in milliseconds
295
- * @returns {string} Formatted duration (e.g., '45s', '2m30s')
295
+ * @returns {string} Formatted duration (e.g., '45s', '2m30s', '1h3m2s')
296
*/
297
export function formatDuration(durationMs) {
298
if (durationMs == null || durationMs < 0) return '0s';
@@ -304,7 +304,8 @@ export function formatDuration(durationMs) {
304
return `${totalSecs}s`;
305
}
306
307
- const mins = Math.floor(totalSecs / 60);
307
+ const hours = Math.floor(totalSecs / 3600);
308
+ const mins = Math.floor((totalSecs % 3600) / 60);
309
const secs = totalSecs % 60;
309
- return `${mins}m${secs}s`;
310
+ return hours ? `${hours}h${mins}m${secs}s` : `${mins}m${secs}s`;
311
}