From 4a06352629627859884796e591629ed15b6bc01c Mon Sep 17 00:00:00 2001
From: Xuan Gu <162244362+xuang7@users.noreply.github.com>
Date: Mon, 25 May 2026 23:05:31 -0700
Subject: [PATCH 1/2] update.
---
frontend/src/app/common/util/format.util.ts | 36 +++++++++++++++++++
.../user/list-item/list-item.component.html | 4 +--
.../user/list-item/list-item.component.ts | 34 ++----------------
...er-computing-unit-list-item.component.html | 2 +-
...user-computing-unit-list-item.component.ts | 27 ++------------
.../dataset-detail.component.ts | 9 ++---
.../detail/hub-workflow-detail.component.ts | 8 ++---
7 files changed, 48 insertions(+), 72 deletions(-)
diff --git a/frontend/src/app/common/util/format.util.ts b/frontend/src/app/common/util/format.util.ts
index 2ac5f229796..9046db5fa3f 100644
--- a/frontend/src/app/common/util/format.util.ts
+++ b/frontend/src/app/common/util/format.util.ts
@@ -54,3 +54,39 @@ export const formatTime = (seconds?: number): string => {
return min === 0 ? `${h}h` : `${h}h${min}m`;
};
+
+/**
+ * Format a past timestamp as a relative time string (e.g. "5 minutes ago").
+ */
+export const formatRelativeTime = (timestamp: number | undefined): string => {
+ if (timestamp === undefined) {
+ return "Unknown";
+ }
+
+ const timeDifference = new Date().getTime() - timestamp;
+ const minutesAgo = Math.floor(timeDifference / (1000 * 60));
+ const hoursAgo = Math.floor(timeDifference / (1000 * 60 * 60));
+ const daysAgo = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
+ const weeksAgo = Math.floor(daysAgo / 7);
+
+ if (minutesAgo < 60) {
+ return `${minutesAgo} minutes ago`;
+ } else if (hoursAgo < 24) {
+ return `${hoursAgo} hours ago`;
+ } else if (daysAgo < 7) {
+ return `${daysAgo} days ago`;
+ } else if (weeksAgo < 4) {
+ return `${weeksAgo} weeks ago`;
+ }
+ return new Date(timestamp).toLocaleDateString();
+};
+
+/**
+ * Format a count, abbreviating values >= 1000 (e.g. 1500 -> "1.5k").
+ */
+export const formatCount = (count: number): string => {
+ if (count >= 1000) {
+ return (count / 1000).toFixed(1) + "k";
+ }
+ return count.toString();
+};
diff --git a/frontend/src/app/dashboard/component/user/list-item/list-item.component.html b/frontend/src/app/dashboard/component/user/list-item/list-item.component.html
index 16e190b41f9..20b98b71ac4 100644
--- a/frontend/src/app/dashboard/component/user/list-item/list-item.component.html
+++ b/frontend/src/app/dashboard/component/user/list-item/list-item.component.html
@@ -147,7 +147,7 @@
nzFlex="90px"
class="resource-info">
Created:
- {{ formatTime(entry.creationTime) }}
+ {{ formatRelativeTime(entry.creationTime) }}
Edited:
- {{ formatTime(entry.lastModifiedTime) }}
+ {{ formatRelativeTime(entry.lastModifiedTime) }}
= 1000) {
- return (count / 1000).toFixed(1) + "k";
- }
- return count.toString();
- }
+ formatCount = formatCount;
// alias for formatSize
formatSize = formatSize;
diff --git a/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit-list-item/user-computing-unit-list-item.component.html b/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit-list-item/user-computing-unit-list-item.component.html
index 740fc73bcc0..fb8afa729e8 100644
--- a/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit-list-item/user-computing-unit-list-item.component.html
+++ b/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit-list-item/user-computing-unit-list-item.component.html
@@ -151,7 +151,7 @@
nzFlex="100px"
class="resource-info">
Created:
- {{ formatTime(unit.creationTime) }}
+ {{ formatRelativeTime(unit.creationTime) }}
1 || (this.gpuOptions.length === 1 && this.gpuOptions[0] !== "0");
}
- formatTime(timestamp: number | undefined): string {
- if (timestamp === undefined) {
- return "Unknown"; // Return "Unknown" if the timestamp is undefined
- }
-
- const currentTime = new Date().getTime();
- const timeDifference = currentTime - timestamp;
-
- const minutesAgo = Math.floor(timeDifference / (1000 * 60));
- const hoursAgo = Math.floor(timeDifference / (1000 * 60 * 60));
- const daysAgo = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
- const weeksAgo = Math.floor(daysAgo / 7);
-
- if (minutesAgo < 60) {
- return `${minutesAgo} minutes ago`;
- } else if (hoursAgo < 24) {
- return `${hoursAgo} hours ago`;
- } else if (daysAgo < 7) {
- return `${daysAgo} days ago`;
- } else if (weeksAgo < 4) {
- return `${weeksAgo} weeks ago`;
- } else {
- return new Date(timestamp).toLocaleDateString();
- }
- }
+ formatRelativeTime = formatRelativeTime;
public async onClickOpenShareAccess(cuid: number): Promise {
this.computingUnitActionsService.openShareAccessModal(cuid, false);
diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts
index 85ca8d27cc0..cc2d794ced4 100644
--- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts
+++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts
@@ -41,7 +41,7 @@ import { NzModalService } from "ng-zorro-antd/modal";
import { AdminSettingsService } from "../../../../service/admin/settings/admin-settings.service";
import { HttpErrorResponse, HttpStatusCode } from "@angular/common/http";
import { Subscription } from "rxjs";
-import { formatSpeed, formatTime } from "src/app/common/util/format.util";
+import { formatCount, formatSpeed, formatTime } from "src/app/common/util/format.util";
import { format } from "date-fns";
import { NgIf, NgClass, NgFor } from "@angular/common";
import { NzCardComponent, NzCardMetaComponent } from "ng-zorro-antd/card";
@@ -725,12 +725,7 @@ export class DatasetDetailComponent implements OnInit {
// alias for formatSize
formatSize = formatSize;
- formatCount(count: number): string {
- if (count >= 1000) {
- return (count / 1000).toFixed(1) + "k";
- }
- return count.toString();
- }
+ formatCount = formatCount;
formatTime = formatTime;
formatSpeed = formatSpeed;
diff --git a/frontend/src/app/hub/component/workflow/detail/hub-workflow-detail.component.ts b/frontend/src/app/hub/component/workflow/detail/hub-workflow-detail.component.ts
index 5eb1561b50b..5d76382f769 100644
--- a/frontend/src/app/hub/component/workflow/detail/hub-workflow-detail.component.ts
+++ b/frontend/src/app/hub/component/workflow/detail/hub-workflow-detail.component.ts
@@ -41,6 +41,7 @@ import { MarkdownDescriptionComponent } from "../../../../dashboard/component/us
import { WorkflowEditorComponent } from "../../../../workspace/component/workflow-editor/workflow-editor.component";
import { MiniMapComponent } from "../../../../workspace/component/workflow-editor/mini-map/mini-map.component";
import { FormlyRepeatDndComponent } from "../../../../common/formly/repeat-dnd/repeat-dnd.component";
+import { formatCount } from "../../../../common/util/format.util";
export const THROTTLE_TIME_MS = 1000;
@@ -266,12 +267,7 @@ export class HubWorkflowDetailComponent implements AfterViewInit, OnDestroy, OnI
}
}
- formatCount(count: number): string {
- if (count >= 1000) {
- return (count / 1000).toFixed(1) + "k";
- }
- return count.toString();
- }
+ formatCount = formatCount;
changeViewDisplayStyle() {
this.displayPreciseViewCount = !this.displayPreciseViewCount;
From 2f836a784d8fe20bbca1938a2b0b36033f5fd56f Mon Sep 17 00:00:00 2001
From: Xuan Gu <162244362+xuang7@users.noreply.github.com>
Date: Tue, 26 May 2026 12:08:51 -0700
Subject: [PATCH 2/2] adds tests.
---
.../src/app/common/util/format.util.spec.ts | 140 ++++++++++++++++++
1 file changed, 140 insertions(+)
create mode 100644 frontend/src/app/common/util/format.util.spec.ts
diff --git a/frontend/src/app/common/util/format.util.spec.ts b/frontend/src/app/common/util/format.util.spec.ts
new file mode 100644
index 00000000000..54f7710a59a
--- /dev/null
+++ b/frontend/src/app/common/util/format.util.spec.ts
@@ -0,0 +1,140 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { formatCount, formatRelativeTime, formatSpeed, formatTime } from "./format.util";
+
+describe("formatSpeed", () => {
+ it('returns "0.0 MB/s" for zero, negative, or undefined input', () => {
+ expect(formatSpeed(0)).toBe("0.0 MB/s");
+ expect(formatSpeed(-1)).toBe("0.0 MB/s");
+ expect(formatSpeed(undefined)).toBe("0.0 MB/s");
+ });
+
+ it("converts bytes/s to MB/s with one decimal place", () => {
+ // exactly 1 MiB/s
+ expect(formatSpeed(1024 * 1024)).toBe("1.0 MB/s");
+ // 2.5 MiB/s
+ expect(formatSpeed(2.5 * 1024 * 1024)).toBe("2.5 MB/s");
+ });
+
+ it("handles sub-MB throughput by rounding to one decimal", () => {
+ // 512 KiB/s ≈ 0.5 MB/s
+ expect(formatSpeed(512 * 1024)).toBe("0.5 MB/s");
+ });
+
+ it("handles very large throughput without overflow", () => {
+ const result = formatSpeed(10 * 1024 * 1024 * 1024); // 10 GiB/s
+ expect(result).toBe("10240.0 MB/s");
+ });
+});
+
+describe("formatTime", () => {
+ it('returns "1s" for undefined, zero, or negative input', () => {
+ expect(formatTime(undefined)).toBe("1s");
+ expect(formatTime(0)).toBe("1s");
+ expect(formatTime(-5)).toBe("1s");
+ });
+
+ it("formats sub-minute durations in seconds", () => {
+ expect(formatTime(1)).toBe("1s");
+ expect(formatTime(45)).toBe("45s");
+ expect(formatTime(59)).toBe("59s");
+ });
+
+ it("rounds fractional seconds", () => {
+ expect(formatTime(1.4)).toBe("1s");
+ expect(formatTime(1.6)).toBe("2s");
+ });
+
+ it("formats durations under one hour as minutes with optional seconds", () => {
+ expect(formatTime(60)).toBe("1m");
+ expect(formatTime(90)).toBe("1m30s");
+ expect(formatTime(125)).toBe("2m05s"); // seconds zero-padded
+ expect(formatTime(3599)).toBe("59m59s");
+ });
+
+ it("formats durations of one hour or more as hours with optional minutes", () => {
+ expect(formatTime(3600)).toBe("1h");
+ expect(formatTime(3660)).toBe("1h1m");
+ expect(formatTime(7200)).toBe("2h");
+ expect(formatTime(7260)).toBe("2h1m");
+ // residual seconds are dropped once we hit the hour bucket
+ expect(formatTime(3600 + 59)).toBe("1h");
+ });
+});
+
+describe("formatRelativeTime", () => {
+ const NOW = new Date("2026-05-26T12:00:00Z").getTime();
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date(NOW));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('returns "Unknown" when timestamp is undefined', () => {
+ expect(formatRelativeTime(undefined)).toBe("Unknown");
+ });
+
+ it("formats sub-hour differences in minutes", () => {
+ expect(formatRelativeTime(NOW - 5 * 60 * 1000)).toBe("5 minutes ago");
+ expect(formatRelativeTime(NOW - 59 * 60 * 1000)).toBe("59 minutes ago");
+ // boundary: just-now floors to 0
+ expect(formatRelativeTime(NOW)).toBe("0 minutes ago");
+ });
+
+ it("formats sub-day differences in hours", () => {
+ expect(formatRelativeTime(NOW - 60 * 60 * 1000)).toBe("1 hours ago");
+ expect(formatRelativeTime(NOW - 23 * 60 * 60 * 1000)).toBe("23 hours ago");
+ });
+
+ it("formats sub-week differences in days", () => {
+ expect(formatRelativeTime(NOW - 24 * 60 * 60 * 1000)).toBe("1 days ago");
+ expect(formatRelativeTime(NOW - 6 * 24 * 60 * 60 * 1000)).toBe("6 days ago");
+ });
+
+ it("formats sub-month differences in weeks", () => {
+ expect(formatRelativeTime(NOW - 7 * 24 * 60 * 60 * 1000)).toBe("1 weeks ago");
+ expect(formatRelativeTime(NOW - 3 * 7 * 24 * 60 * 60 * 1000)).toBe("3 weeks ago");
+ });
+
+ it("falls back to a locale date string for differences beyond four weeks", () => {
+ const oldTimestamp = NOW - 5 * 7 * 24 * 60 * 60 * 1000;
+ const expected = new Date(oldTimestamp).toLocaleDateString();
+ expect(formatRelativeTime(oldTimestamp)).toBe(expected);
+ });
+});
+
+describe("formatCount", () => {
+ it("renders counts under 1000 as plain integers", () => {
+ expect(formatCount(0)).toBe("0");
+ expect(formatCount(1)).toBe("1");
+ expect(formatCount(999)).toBe("999");
+ });
+
+ it("abbreviates counts of 1000+ to one-decimal thousands", () => {
+ expect(formatCount(1000)).toBe("1.0k");
+ expect(formatCount(1500)).toBe("1.5k");
+ expect(formatCount(12345)).toBe("12.3k");
+ expect(formatCount(999999)).toBe("1000.0k");
+ });
+});