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"); + }); +}); 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;