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 }