diff --git a/client/package.json b/client/package.json
index de12b9f71852..a5ccfd526d6f 100644
--- a/client/package.json
+++ b/client/package.json
@@ -200,6 +200,7 @@
"sass-loader": "^13.3.2",
"store": "^2.0.12",
"style-loader": "^3.3.3",
+ "timezone-mock": "^1.3.6",
"ts-jest": "^29.2.3",
"ts-loader": "^9.5.0",
"tsconfig-paths-webpack-plugin": "^4.1.0",
diff --git a/client/src/components/Markdown/Elements/InvocationTime.vue b/client/src/components/Markdown/Elements/InvocationTime.vue
index b335bb377974..1dfb74342b12 100644
--- a/client/src/components/Markdown/Elements/InvocationTime.vue
+++ b/client/src/components/Markdown/Elements/InvocationTime.vue
@@ -5,6 +5,8 @@
diff --git a/client/src/utils/dates.test.ts b/client/src/utils/dates.test.ts
new file mode 100644
index 000000000000..9a1c690929f3
--- /dev/null
+++ b/client/src/utils/dates.test.ts
@@ -0,0 +1,51 @@
+import MockDate from "timezone-mock";
+
+import { formatGalaxyPrettyDateString, galaxyTimeToDate, localizeUTCPretty } from "./dates";
+
+describe("dates.ts", () => {
+ beforeEach(() => {
+ MockDate.register("Etc/GMT+4");
+ });
+
+ afterEach(() => {
+ MockDate.unregister();
+ });
+
+ describe("galaxyTimeToDate", () => {
+ it("should convert valid galaxyTime string to Date object", () => {
+ const galaxyTime = "2023-10-01T12:00:00";
+ const date = galaxyTimeToDate(galaxyTime);
+ expect(date).toBeInstanceOf(Date);
+ expect(date.toISOString()).toBe("2023-10-01T12:00:00.000Z");
+ });
+
+ it("should append Z if missing and parse correctly", () => {
+ const galaxyTime = "2023-10-01T12:00:00";
+ const date = galaxyTimeToDate(galaxyTime);
+ expect(date.toISOString()).toBe("2023-10-01T12:00:00.000Z");
+ });
+
+ it("should throw an error for invalid galaxyTime string", () => {
+ const invalidGalaxyTime = "invalid-date-string";
+ expect(() => galaxyTimeToDate(invalidGalaxyTime)).toThrow(
+ `Invalid galaxyTime string: ${invalidGalaxyTime}`
+ );
+ });
+ });
+
+ describe("localizeUTCPretty", () => {
+ it("should format Date object into human-readable string", () => {
+ const date = new Date("2023-10-01T12:00:00Z");
+ const formatted = localizeUTCPretty(date);
+ expect(formatted).toBe("Sunday Oct 1st 8:00:00 2023 GMT-4");
+ });
+ });
+
+ describe("formatGalaxyPrettyDateString", () => {
+ it("should convert galaxyTime string to formatted date string", () => {
+ const galaxyTime = "2023-10-01T12:00:00";
+ const formatted = formatGalaxyPrettyDateString(galaxyTime);
+ expect(formatted).toBe("Sunday Oct 1st 8:00:00 2023 GMT-4");
+ });
+ });
+});
diff --git a/client/src/utils/dates.ts b/client/src/utils/dates.ts
new file mode 100644
index 000000000000..7903a56561b5
--- /dev/null
+++ b/client/src/utils/dates.ts
@@ -0,0 +1,39 @@
+import { format, parseISO } from "date-fns";
+
+/**
+ * Converts a Galaxy time string to a Date object.
+ * @param {string} galaxyTime - The Galaxy time string in ISO format.
+ * @returns {Date} The parsed Date object.
+ */
+export function galaxyTimeToDate(galaxyTime: string): Date {
+ // Galaxy doesn't include Zulu time zone designator, but it's always UTC
+ // so we need to add it to parse the string correctly in JavaScript.
+ let time = galaxyTime;
+ if (!time.endsWith("Z")) {
+ time += "Z";
+ }
+ const date = parseISO(time);
+ if (isNaN(date.getTime())) {
+ throw new Error(`Invalid galaxyTime string: ${galaxyTime}`);
+ }
+ return date;
+}
+
+/**
+ * Formats a UTC Date object into a human-readable string, localized to the user's time zone.
+ * @param {Date} utcDate - The UTC Date object.
+ * @returns {string} The formatted date string.
+ */
+export function localizeUTCPretty(utcDate: Date): string {
+ return format(utcDate, "eeee MMM do H:mm:ss yyyy zz");
+}
+
+/**
+ * Converts a Galaxy time string to a human-readable formatted date string, localized to the user's time zone.
+ * @param {string} galaxyTime - The Galaxy time string in ISO format.
+ * @returns {string} The formatted date string.
+ */
+export function formatGalaxyPrettyDateString(galaxyTime: string): string {
+ const date = galaxyTimeToDate(galaxyTime);
+ return localizeUTCPretty(date);
+}
diff --git a/client/yarn.lock b/client/yarn.lock
index cd6c4bf5f55a..25b9a38cd7be 100644
--- a/client/yarn.lock
+++ b/client/yarn.lock
@@ -11381,6 +11381,11 @@ timers-browserify@^2.0.12:
dependencies:
setimmediate "^1.0.4"
+timezone-mock@^1.3.6:
+ version "1.3.6"
+ resolved "https://registry.yarnpkg.com/timezone-mock/-/timezone-mock-1.3.6.tgz#44e4c5aeb57e6c07ae630a05c528fc4d9aab86f4"
+ integrity sha512-YcloWmZfLD9Li5m2VcobkCDNVaLMx8ohAb/97l/wYS3m+0TIEK5PFNMZZfRcusc6sFjIfxu8qcJT0CNnOdpqmg==
+
tmpl@1.0.5:
version "1.0.5"
resolved "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz"
diff --git a/lib/galaxy/managers/markdown_util.py b/lib/galaxy/managers/markdown_util.py
index 02545399e997..969ef76cb1ef 100644
--- a/lib/galaxy/managers/markdown_util.py
+++ b/lib/galaxy/managers/markdown_util.py
@@ -460,9 +460,7 @@ def handle_instance_organization_link(self, line, title, url):
pass
def handle_invocation_time(self, line, invocation):
- self.ensure_rendering_data_for("invocations", invocation)["create_time"] = invocation.create_time.strftime(
- "%Y-%m-%d, %H:%M:%S"
- )
+ self.ensure_rendering_data_for("invocations", invocation)["create_time"] = invocation.create_time.isoformat()
def handle_dataset_type(self, line, hda):
self.extend_history_dataset_rendering_data(hda, "ext", hda.ext, "*Unknown dataset type*")
@@ -506,6 +504,9 @@ class ToBasicMarkdownDirectiveHandler(GalaxyInternalMarkdownDirectiveHandler):
def __init__(self, trans):
self.trans = trans
+ def _format_printable_time(self, time):
+ return time.strftime("%Y-%m-%d, %H:%M:%S UTC")
+
def handle_dataset_display(self, line, hda):
name = hda.name or ""
markdown = "---\n"
@@ -689,7 +690,7 @@ def handle_generate_galaxy_version(self, line, generate_version):
return (content, True)
def handle_generate_time(self, line, generate_time):
- content = literal_via_fence(generate_time.isoformat())
+ content = literal_via_fence(self._format_printable_time(generate_time))
return (content, True)
def handle_instance_access_link(self, line, url):
@@ -722,7 +723,7 @@ def _handle_link(self, url, title=None):
return (f"[{title}]({url})", True)
def handle_invocation_time(self, line, invocation):
- content = literal_via_fence(invocation.create_time.strftime("%Y-%m-%d, %H:%M:%S"))
+ content = literal_via_fence(self._format_printable_time(invocation.create_time))
return (content, True)
def handle_dataset_name(self, line, hda):
diff --git a/test/unit/app/managers/test_markdown_export.py b/test/unit/app/managers/test_markdown_export.py
index 3470061b359f..9b276e1b07a6 100644
--- a/test/unit/app/managers/test_markdown_export.py
+++ b/test/unit/app/managers/test_markdown_export.py
@@ -280,7 +280,7 @@ def test_generate_invocation_time(self):
invocation = self._new_invocation()
self.app.workflow_manager.get_invocation.side_effect = [invocation]
result = self._to_basic(example)
- expectedtime = invocation.create_time.strftime("%Y-%m-%d, %H:%M:%S")
+ expectedtime = invocation.create_time.strftime("%Y-%m-%d, %H:%M:%S UTC")
assert f"\n {expectedtime}" in result
def test_job_parameters(self):
@@ -413,9 +413,7 @@ def test_get_invocation_time(self):
result, extra_data = self._ready_export(example)
assert "invocations" in extra_data
assert "create_time" in extra_data["invocations"]["be8be0fd2ce547f6"]
- assert extra_data["invocations"]["be8be0fd2ce547f6"]["create_time"] == invocation.create_time.strftime(
- "%Y-%m-%d, %H:%M:%S"
- )
+ assert extra_data["invocations"]["be8be0fd2ce547f6"]["create_time"] == invocation.create_time.isoformat()
def _ready_export(self, example):
return ready_galaxy_markdown_for_export(self.trans, example)