diff --git a/package.json b/package.json index 3cf0ffb6ff5..78288ba104f 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "classnames": "2.5.1", "clone-deep": "4.0.1", "date-fns": "2.30.0", + "date-fns-tz": "2.0.1", "fast-deep-equal": "3.1.3", "formik": "2.4.5", "history": "5.3.0", diff --git a/src/app/base/components/NotificationGroup/Notification/Notification.tsx b/src/app/base/components/NotificationGroup/Notification/Notification.tsx index 46bcb5fcb92..5105aeee264 100644 --- a/src/app/base/components/NotificationGroup/Notification/Notification.tsx +++ b/src/app/base/components/NotificationGroup/Notification/Notification.tsx @@ -13,7 +13,7 @@ import { isUpgradeNotification, } from "@/app/store/notification/utils"; import type { RootState } from "@/app/store/root/types"; -import { getUtcTimestamp } from "@/app/utils/time"; +import { formatUtcDatetime } from "@/app/utils/time"; type Props = { className?: string | null; @@ -32,7 +32,7 @@ const NotificationGroupNotification = ({ const notification = useSelector((state: RootState) => notificationSelectors.getById(state, id) ); - const createdTimestamp = getUtcTimestamp(notification?.created); + const createdTimestamp = formatUtcDatetime(notification?.created); if (!notification) { return null; } diff --git a/src/app/base/components/StatusBar/StatusBar.test.tsx b/src/app/base/components/StatusBar/StatusBar.test.tsx index 4a7b8a7b583..0a12194c1ef 100644 --- a/src/app/base/components/StatusBar/StatusBar.test.tsx +++ b/src/app/base/components/StatusBar/StatusBar.test.tsx @@ -164,8 +164,8 @@ it("displays correct text for machines with hardware sync enabled and no last_sy status: NodeStatus.DEPLOYED, system_id: "abc123", enable_hw_sync: true, - last_sync: factory.timestamp("Thu, 31 Dec. 2020 22:00:00"), - next_sync: factory.timestamp("Thu, 31 Dec. 2020 23:01:00"), + last_sync: factory.timestamp(""), + next_sync: factory.timestamp(""), }), ]; @@ -198,7 +198,7 @@ it("displays last image sync timestamp for a rack or region+rack controller", () renderWithMockStore(, { state }); expect(screen.getByTestId("status-bar-status")).toHaveTextContent( - `Last image sync: ${controller.last_image_sync}` + `Last image sync: Thu, 02 Jun. 2022 00:48:41 (UTC)` ); }); diff --git a/src/app/base/components/StatusBar/StatusBar.tsx b/src/app/base/components/StatusBar/StatusBar.tsx index 3155c4af88f..33e5184b9bb 100644 --- a/src/app/base/components/StatusBar/StatusBar.tsx +++ b/src/app/base/components/StatusBar/StatusBar.tsx @@ -18,9 +18,9 @@ import { isDeployedWithHardwareSync, isMachineDetails, } from "@/app/store/machine/utils"; -import type { UtcTimestamp } from "@/app/store/types/model"; +import type { UtcDatetime } from "@/app/store/types/model"; import { NodeStatus } from "@/app/store/types/node"; -import { getUtcTimestamp, getTimeDistanceString } from "@/app/utils/time"; +import { formatUtcDatetime, getTimeDistanceString } from "@/app/utils/time"; const getLastCommissionedString = (machine: MachineDetails) => { if (machine.status === NodeStatus.COMMISSIONING) { @@ -38,7 +38,7 @@ const getLastCommissionedString = (machine: MachineDetails) => { } }; -const getSyncStatusString = (syncStatus: UtcTimestamp) => { +const getSyncStatusString = (syncStatus: UtcDatetime) => { if (syncStatus === "") { return "Never"; } @@ -88,7 +88,7 @@ export const StatusBar = (): JSX.Element | null => { isControllerDetails(activeController) && (isRack(activeController) || isRegionAndRack(activeController)) ) { - status = `Last image sync: ${getUtcTimestamp( + status = `Last image sync: ${formatUtcDatetime( activeController.last_image_sync )}`; } diff --git a/src/app/base/components/node/NodeTestsTable/NodeTestsTable.tsx b/src/app/base/components/node/NodeTestsTable/NodeTestsTable.tsx index 77b283f1035..446afffb471 100644 --- a/src/app/base/components/node/NodeTestsTable/NodeTestsTable.tsx +++ b/src/app/base/components/node/NodeTestsTable/NodeTestsTable.tsx @@ -19,7 +19,7 @@ import type { ScriptResult } from "@/app/store/scriptresult/types"; import { ScriptResultType } from "@/app/store/scriptresult/types"; import { canBeSuppressed } from "@/app/store/scriptresult/utils"; import { nodeIsMachine } from "@/app/store/utils"; -import { getUtcTimestamp } from "@/app/utils/time"; +import { formatUtcDatetime } from "@/app/utils/time"; export enum ScriptResultAction { VIEW_METRICS = "viewMetrics", @@ -132,7 +132,7 @@ const NodeTestsTable = ({ node, scriptResults }: Props): JSX.Element => { }, { className: "date-col", - content: getUtcTimestamp(result.updated), + content: formatUtcDatetime(result.updated), }, { className: "runtime-col", diff --git a/src/app/networkDiscovery/views/DiscoveriesList/DiscoveriesList.tsx b/src/app/networkDiscovery/views/DiscoveriesList/DiscoveriesList.tsx index 3da80adfd54..8f667773ef7 100644 --- a/src/app/networkDiscovery/views/DiscoveriesList/DiscoveriesList.tsx +++ b/src/app/networkDiscovery/views/DiscoveriesList/DiscoveriesList.tsx @@ -25,6 +25,7 @@ import type { Discovery } from "@/app/store/discovery/types"; import { DiscoveryMeta } from "@/app/store/discovery/types"; import type { RootState } from "@/app/store/root/types"; import { generateEmptyStateMsg, getTableStatus } from "@/app/utils"; +import { formatUtcDatetime } from "@/app/utils/time"; export enum Labels { DiscoveriesList = "Discoveries list", @@ -78,7 +79,11 @@ const generateRows = ( content: discovery.observer_hostname, }, { - content:
{discovery.last_seen}
, + content: ( +
+ {formatUtcDatetime(discovery.last_seen)} +
+ ), }, { content: ( diff --git a/src/app/settings/views/Dhcp/DhcpList/DhcpList.tsx b/src/app/settings/views/Dhcp/DhcpList/DhcpList.tsx index 69c379a7e75..5de926aa5ec 100644 --- a/src/app/settings/views/Dhcp/DhcpList/DhcpList.tsx +++ b/src/app/settings/views/Dhcp/DhcpList/DhcpList.tsx @@ -33,7 +33,7 @@ import type { RootState } from "@/app/store/root/types"; import { subnetActions } from "@/app/store/subnet"; import subnetSelectors from "@/app/store/subnet/selectors"; import type { Subnet } from "@/app/store/subnet/types"; -import { formatUtcTimestamp } from "@/app/utils/time"; +import { formatUtcDatetime } from "@/app/utils/time"; const getTargetName = ( controllers: Controller[], @@ -80,7 +80,7 @@ const generateRows = ( const expanded = expandedId === dhcpsnippet.id; // Dates are in the format: Thu, 15 Aug. 2019 06:21:39. const updated = dhcpsnippet.updated - ? formatUtcTimestamp(dhcpsnippet.updated) + ? formatUtcDatetime(dhcpsnippet.updated) : "Never"; const enabled = dhcpsnippet.enabled ? "Yes" : "No"; const showDelete = expandedType === "delete"; diff --git a/src/app/settings/views/Scripts/ScriptsList/ScriptsList.test.tsx b/src/app/settings/views/Scripts/ScriptsList/ScriptsList.test.tsx index 8bb51eb836b..9de2e14d0b2 100644 --- a/src/app/settings/views/Scripts/ScriptsList/ScriptsList.test.tsx +++ b/src/app/settings/views/Scripts/ScriptsList/ScriptsList.test.tsx @@ -315,7 +315,7 @@ describe("ScriptsList", () => { ); expect( within(screen.getByRole("row", { name: "test name 33" })).getByText( - "2020-12-31 22:59" + "Thu, 31 Dec. 2020 22:59:00 (UTC)" ) ).toBeInTheDocument(); }); @@ -326,7 +326,7 @@ describe("ScriptsList", () => { loaded: true, items: [ factory.script({ - created: factory.timestamp(""), + created: () => factory.timestamp(""), script_type: ScriptType.TESTING, }), ], diff --git a/src/app/settings/views/Scripts/ScriptsList/ScriptsList.tsx b/src/app/settings/views/Scripts/ScriptsList/ScriptsList.tsx index 3ac9498df3d..05c3fee0bae 100644 --- a/src/app/settings/views/Scripts/ScriptsList/ScriptsList.tsx +++ b/src/app/settings/views/Scripts/ScriptsList/ScriptsList.tsx @@ -1,7 +1,6 @@ import { useEffect, useState } from "react"; import { ContentSection } from "@canonical/maas-react-components"; -import { format } from "date-fns"; import { useDispatch, useSelector } from "react-redux"; import type { Dispatch } from "redux"; @@ -17,7 +16,7 @@ import { scriptActions } from "@/app/store/script"; import scriptSelectors from "@/app/store/script/selectors"; import type { Script } from "@/app/store/script/types"; import { generateEmptyStateMsg, getTableStatus } from "@/app/utils"; -import { parseUtcDatetime } from "@/app/utils/time"; +import { formatUtcDatetime } from "@/app/utils/time"; export enum Labels { Actions = "Table actions", @@ -49,7 +48,7 @@ const generateRows = ( // history timestamps are in the format: Mon, 02 Sep 2019 02:02:39 -0000 let uploadedOn: string; try { - uploadedOn = format(parseUtcDatetime(script.created), "yyyy-LL-dd H:mm"); + uploadedOn = formatUtcDatetime(script.created); } catch (error) { uploadedOn = "Never"; } diff --git a/src/app/settings/views/Users/UsersList/UsersList.tsx b/src/app/settings/views/Users/UsersList/UsersList.tsx index 66d235a145f..3247dfdfbd1 100644 --- a/src/app/settings/views/Users/UsersList/UsersList.tsx +++ b/src/app/settings/views/Users/UsersList/UsersList.tsx @@ -22,7 +22,7 @@ import { userActions } from "@/app/store/user"; import userSelectors from "@/app/store/user/selectors"; import type { User } from "@/app/store/user/types"; import { isComparable } from "@/app/utils"; -import { formatUtcTimestamp } from "@/app/utils/time"; +import { formatUtcDatetime } from "@/app/utils/time"; type SortKey = keyof User; @@ -35,7 +35,7 @@ const generateUserRows = ( const isAuthUser = user.id === authUser?.id; // Dates are in the format: Thu, 15 Aug. 2019 06:21:39. const last_login = user.last_login - ? formatUtcTimestamp(user.last_login) + ? formatUtcDatetime(user.last_login) : "Never"; const fullName = user.last_name; return { diff --git a/src/app/store/bootresource/types/base.ts b/src/app/store/bootresource/types/base.ts index 08402eeb724..6a73e9b0e71 100644 --- a/src/app/store/bootresource/types/base.ts +++ b/src/app/store/bootresource/types/base.ts @@ -1,6 +1,6 @@ import type { BootResourceSourceType, BootResourceType } from "./enum"; -import type { Model, UtcTimestamp } from "@/app/store/types/model"; +import type { Model, UtcDatetime } from "@/app/store/types/model"; export type BaseImageFields = { checked: boolean; @@ -15,8 +15,8 @@ export type BootResource = Model & { complete: boolean; downloading: boolean; icon: "in-progress" | "queued" | "succeeded" | "waiting"; - lastUpdate: UtcTimestamp; - lastDeployed: UtcTimestamp; + lastUpdate: UtcDatetime; + lastDeployed: UtcDatetime; machineCount: number; name: string; numberOfNodes: number; diff --git a/src/app/store/controller/types/base.ts b/src/app/store/controller/types/base.ts index e9768088684..0a65a5a6901 100644 --- a/src/app/store/controller/types/base.ts +++ b/src/app/store/controller/types/base.ts @@ -11,7 +11,7 @@ import type { PowerState, StorageLayout } from "@/app/store/types/enum"; import type { ModelRef, TimestampFields, - UtcTimestamp, + UtcDatetime, } from "@/app/store/types/model"; import type { NodeActions, @@ -69,7 +69,7 @@ export type ControllerActions = export type BaseController = BaseNode & { actions: ControllerActions[]; - last_image_sync: UtcTimestamp; + last_image_sync: UtcDatetime; link_type: NodeLinkType.CONTROLLER; node_type_display: | NodeTypeDisplay.RACK_CONTROLLER diff --git a/src/app/store/discovery/selectors.test.ts b/src/app/store/discovery/selectors.test.ts index b4ba5d16008..ceabed0bbc7 100644 --- a/src/app/store/discovery/selectors.test.ts +++ b/src/app/store/discovery/selectors.test.ts @@ -62,7 +62,7 @@ describe("discovery selectors", () => { mac_organization: "Acme Inc.", ip: "0.0.0.0", observer_hostname: "alpha", - last_seen: "Mon, 19 Oct. 2020 01:15:57", + last_seen: factory.timestamp("Mon, 19 Oct. 2020 01:15:57"), }), factory.discovery({ hostname: "bar", @@ -70,7 +70,7 @@ describe("discovery selectors", () => { mac_organization: "Foodies Inc.", ip: "1.1.1.1", observer_hostname: "bravo", - last_seen: "Sat, 17 Oct. 2020 01:15:57", + last_seen: factory.timestamp("Sat, 17 Oct. 2020 01:15:57"), }), factory.discovery({ hostname: "foobar", @@ -78,7 +78,7 @@ describe("discovery selectors", () => { mac_organization: "Roxxon", ip: "2.2.2.2", observer_hostname: "foot", - last_seen: "Mon, 19 Oct. 2020 01:15:57", + last_seen: factory.timestamp("Mon, 19 Oct. 2020 01:15:57"), }), factory.discovery({ hostname: "fizz", @@ -86,7 +86,7 @@ describe("discovery selectors", () => { mac_organization: "Pacific Couriers", ip: "3.3.3.3", observer_hostname: "alpha", - last_seen: "Mon, 19 Oct. 2020 01:15:57", + last_seen: factory.timestamp("Mon, 19 Oct. 2020 01:15:57"), }), ], }), diff --git a/src/app/store/discovery/types/base.ts b/src/app/store/discovery/types/base.ts index b4ed8ba6450..a600fb43f20 100644 --- a/src/app/store/discovery/types/base.ts +++ b/src/app/store/discovery/types/base.ts @@ -1,5 +1,5 @@ import type { APIError } from "@/app/base/types"; -import type { Model } from "@/app/store/types/model"; +import type { Model, UtcDatetime } from "@/app/store/types/model"; import type { GenericState } from "@/app/store/types/state"; export type Discovery = Model & { @@ -10,7 +10,7 @@ export type Discovery = Model & { hostname: string | null; ip: string | null; is_external_dhcp: boolean | null; - last_seen: string; + last_seen: UtcDatetime; mac_address: string | null; mac_organization: string; mdns: number | null; diff --git a/src/app/store/machine/types/base.ts b/src/app/store/machine/types/base.ts index e265e42406a..b53e6b4401b 100644 --- a/src/app/store/machine/types/base.ts +++ b/src/app/store/machine/types/base.ts @@ -8,7 +8,7 @@ import type { PowerState, StorageLayout } from "@/app/store/types/enum"; import type { ModelRef, TimestampFields, - UtcTimestamp, + UtcDatetime, } from "@/app/store/types/model"; import type { BaseNode, @@ -73,7 +73,7 @@ export type MachineDetails = BaseMachine & bmc: number; boot_disk: Disk | null; certificate?: CertificateMetadata; - commissioning_start_time: UtcTimestamp; + commissioning_start_time: UtcDatetime; commissioning_status: TestStatus; cpu_speed: BaseNode["cpu_speed"]; cpu_test_status: TestStatus; @@ -126,8 +126,8 @@ type HardwareSyncFields = } | { enable_hw_sync: true; - last_sync: UtcTimestamp; - next_sync: UtcTimestamp; + last_sync: UtcDatetime; + next_sync: UtcDatetime; is_sync_healthy: boolean; sync_interval: Seconds; }; diff --git a/src/app/store/scriptresult/types/base.ts b/src/app/store/scriptresult/types/base.ts index b81a85567b4..54afbdcb486 100644 --- a/src/app/store/scriptresult/types/base.ts +++ b/src/app/store/scriptresult/types/base.ts @@ -9,7 +9,7 @@ import type { import type { HardwareType } from "@/app/base/enum"; import type { APIError } from "@/app/base/types"; -import type { Model, UtcTimestamp } from "@/app/store/types/model"; +import type { Model, UtcDatetime } from "@/app/store/types/model"; import type { NetworkInterface } from "@/app/store/types/node"; import type { GenericState } from "@/app/store/types/state"; @@ -29,7 +29,7 @@ export type PartialScriptResult = Model & { status: ScriptResultStatus; status_name: string; suppressed: boolean; - updated?: UtcTimestamp; + updated?: UtcDatetime; }; export type ScriptResult = PartialScriptResult & { @@ -76,7 +76,7 @@ export type ScriptResult = PartialScriptResult & { results: ScriptResultResult[]; script?: number; script_version?: number | null; - started?: UtcTimestamp; + started?: UtcDatetime; tags: string; }; diff --git a/src/app/store/types/model.ts b/src/app/store/types/model.ts index 8d48cdd8fe7..e3630552a32 100644 --- a/src/app/store/types/model.ts +++ b/src/app/store/types/model.ts @@ -3,10 +3,11 @@ export type Model = { }; // Expected format: "Thu, 15 Aug. 2019 06:21:39" or "" -export type UtcTimestamp = string & { readonly __brand: unique symbol }; +export type UtcDatetime = string & { readonly __brand: unique symbol }; +export type UtcDatetimeDisplay = `${string} (UTC)` | "Never"; export type TimestampFields = { - created: UtcTimestamp; - updated: UtcTimestamp; + created: UtcDatetime; + updated: UtcDatetime; }; export type TimestampedModel = Model & TimestampFields; diff --git a/src/app/store/user/types/base.ts b/src/app/store/user/types/base.ts index 8891dbfef2d..5b9f9f9e312 100644 --- a/src/app/store/user/types/base.ts +++ b/src/app/store/user/types/base.ts @@ -1,5 +1,5 @@ import type { APIError } from "@/app/base/types"; -import type { Model, UtcTimestamp } from "@/app/store/types/model"; +import type { Model, UtcDatetime } from "@/app/store/types/model"; import type { GenericState } from "@/app/store/types/state"; export type User = Model & { @@ -9,7 +9,7 @@ export type User = Model & { is_local: boolean; is_superuser: boolean; last_name: string; - last_login: UtcTimestamp; + last_login: UtcDatetime; machines_count: number; sshkeys_count: number; username: string; diff --git a/src/app/tags/views/TagList/TagTable/TagTable.tsx b/src/app/tags/views/TagList/TagTable/TagTable.tsx index 4ff736f2faf..539eab37758 100644 --- a/src/app/tags/views/TagList/TagTable/TagTable.tsx +++ b/src/app/tags/views/TagList/TagTable/TagTable.tsx @@ -24,7 +24,7 @@ import type { Tag } from "@/app/store/tag/types"; import { TagMeta } from "@/app/store/tag/types"; import AppliedTo from "@/app/tags/components/AppliedTo"; import { isComparable } from "@/app/utils"; -import { getUtcTimestamp } from "@/app/utils/time"; +import { formatUtcDatetime } from "@/app/utils/time"; type Props = PropsWithSpread< { @@ -76,7 +76,7 @@ const generateRows = ( }, { "aria-label": Label.Updated, - content: getUtcTimestamp(tag.updated), + content: formatUtcDatetime(tag.updated), }, { "aria-label": Label.Auto, diff --git a/src/app/tags/views/TagUpdate/TagUpdateFormFields/TagUpdateFormFields.tsx b/src/app/tags/views/TagUpdate/TagUpdateFormFields/TagUpdateFormFields.tsx index 17e06ab8359..2144082bbb0 100644 --- a/src/app/tags/views/TagUpdate/TagUpdateFormFields/TagUpdateFormFields.tsx +++ b/src/app/tags/views/TagUpdate/TagUpdateFormFields/TagUpdateFormFields.tsx @@ -12,7 +12,7 @@ import AppliedTo from "@/app/tags/components/AppliedTo"; import DefinitionField from "@/app/tags/components/DefinitionField"; import KernelOptionsField from "@/app/tags/components/KernelOptionsField"; import { Label } from "@/app/tags/views/TagDetails"; -import { getUtcTimestamp } from "@/app/utils/time"; +import { formatUtcDatetime } from "@/app/utils/time"; type Props = { id: Tag[TagMeta.PK]; @@ -41,7 +41,7 @@ export const TagUpdateFormFields = ({ id }: Props): JSX.Element | null => { diff --git a/src/app/utils/time.test.ts b/src/app/utils/time.test.ts index 62944bbd326..510c5d4bae5 100644 --- a/src/app/utils/time.test.ts +++ b/src/app/utils/time.test.ts @@ -2,59 +2,74 @@ import MockDate from "mockdate"; import timezoneMock from "timezone-mock"; import { - formatUtcTimestamp, + formatUtcDatetime, getTimeDistanceString, - getUtcTimestamp, + parseUtcDatetime, } from "./time"; -import type { UtcTimestamp } from "@/app/store/types/model"; -import * as factory from "@/testing/factories"; +import type { UtcDatetime } from "@/app/store/types/model"; beforeEach(() => { MockDate.set("Fri, 18 Nov. 2022 01:01:00"); + timezoneMock.register("Etc/GMT+5"); }); afterEach(() => { MockDate.reset(); + timezoneMock.unregister(); }); describe("getTimeDistanceString", () => { it("returns time distance for UTC TimeString in the past", () => { expect( - getTimeDistanceString("Fri, 18 Nov. 2022 01:00:50" as UtcTimestamp) + getTimeDistanceString("Fri, 18 Nov. 2022 01:00:50" as UtcDatetime) ).toEqual("less than a minute ago"); }); it("returns time distance for UTC TimeString in the future", () => { expect( - getTimeDistanceString("Fri, 18 Nov. 2022 01:01:10" as UtcTimestamp) + getTimeDistanceString("Fri, 18 Nov. 2022 01:01:10" as UtcDatetime) ).toEqual("in less than a minute"); }); }); -describe("formatUtcTimestamp", () => { +describe("formatUtcDatetime", () => { it("returns UTC date time in a correct format", () => { + timezoneMock.register("Etc/GMT+0"); expect( - formatUtcTimestamp("Fri, 18 Nov. 2022 01:00:50" as UtcTimestamp) - ).toEqual("Fri, 18 Nov. 2022 01:00:50"); + formatUtcDatetime("Fri, 18 Nov. 2022 01:00:50" as UtcDatetime) + ).toEqual("Fri, 18 Nov. 2022 01:00:50 (UTC)"); }); - it("returns UTC date time in local time", () => { + it("returns UTC date time in UTC regardless of timezone", () => { timezoneMock.register("Etc/GMT-1"); expect( - formatUtcTimestamp("Fri, 18 Nov. 2022 03:00:00" as UtcTimestamp) - ).toEqual("Fri, 18 Nov. 2022 04:00:00"); + formatUtcDatetime("Fri, 18 Nov. 2022 03:00:00" as UtcDatetime) + ).toEqual("Fri, 18 Nov. 2022 03:00:00 (UTC)"); + }); + it("returns Never if no time is provided", () => { + const inputTimeString = "" as UtcDatetime; + const expectedOutput = "Never"; + expect(formatUtcDatetime(inputTimeString)).toEqual(expectedOutput); }); -}); -describe("getUtcTimestamp", () => { it("appends (UTC) to the given time string", () => { - const inputTimeString = "Fri, 18 Nov. 2022 01:00:50" as UtcTimestamp; + const inputTimeString = "Fri, 18 Nov. 2022 01:00:50" as UtcDatetime; const expectedOutput = "Fri, 18 Nov. 2022 01:00:50 (UTC)"; - expect(getUtcTimestamp(inputTimeString)).toEqual(expectedOutput); + expect(formatUtcDatetime(inputTimeString)).toEqual(expectedOutput); + }); +}); + +describe("parseUtcDatetime", () => { + it("parses UTC time string into Date object correctly", () => { + const utcTimeString = "Fri, 18 Nov. 2022 01:00:50" as UtcDatetime; + const expectedDate = new Date(Date.UTC(2022, 10, 18, 1, 0, 50)); // Fri, 18 Nov. 2022 01:00:50 + const result = parseUtcDatetime(utcTimeString); + expect(result).toEqual(expectedDate); }); - it("works with different date formats", () => { - const inputTimeString = factory.timestamp("2022-11-18T01:00:50Z"); - const expectedOutput = "2022-11-18T01:00:50Z (UTC)"; - expect(getUtcTimestamp(inputTimeString)).toEqual(expectedOutput); + it("handles leap years correctly", () => { + const utcTimeString = "Mon, 29 Feb. 2016 12:00:00" as UtcDatetime; + const expectedDate = new Date(Date.UTC(2016, 1, 29, 12, 0, 0)); // Mon, 29 Feb. 2016 12:00:00 + const result = parseUtcDatetime(utcTimeString); + expect(result).toEqual(expectedDate); }); }); diff --git a/src/app/utils/time.ts b/src/app/utils/time.ts index e342ecf8223..4706341a9d1 100644 --- a/src/app/utils/time.ts +++ b/src/app/utils/time.ts @@ -1,40 +1,40 @@ -import { format, formatDistance, parse } from "date-fns"; +import { formatDistance, parse } from "date-fns"; +import { formatInTimeZone } from "date-fns-tz"; -import type { UtcTimestamp } from "@/app/store/types/model"; +import type { UtcDatetime, UtcDatetimeDisplay } from "@/app/store/types/model"; -const DATETIME_FORMAT = "E, dd LLL. yyyy HH:mm:ss"; -const UTC_DATETIME_FORMAT = `${DATETIME_FORMAT} x`; +const DATETIME_DISPLAY_FORMAT = "E, dd LLL. yyyy HH:mm:ss"; -export const parseUtcDatetime = (utcTimeString: UtcTimestamp): Date => +export const parseUtcDatetime = (utcTimeString: UtcDatetime): Date => parse( `${utcTimeString} +00`, // let parse fn know it's UTC - UTC_DATETIME_FORMAT, + `${DATETIME_DISPLAY_FORMAT} x`, new Date() ); -export const getTimeDistanceString = (utcTimeString: UtcTimestamp): string => +export const getTimeDistanceString = (utcTimeString: UtcDatetime) => formatDistance(parseUtcDatetime(utcTimeString), new Date(), { addSuffix: true, }); /** - * Appends "(UTC)" to a given string to indicate the time zone explicitly. - * @param utcTimeString - time string in UTC - * @returns Time string with appended "(UTC)" + * @param utcTimeString - time string in UTC_DATETIME_FORMAT + * @returns time string adjusted for local time zone in DATETIME_FORMAT */ -export const getUtcTimestamp = (utcTimeString?: UtcTimestamp): string => - utcTimeString ? utcTimeString + " (UTC)" : ""; +export const formatUtcDatetime = ( + utcTimeString?: UtcDatetime +): UtcDatetimeDisplay => { + if (!utcTimeString) return "Never"; -/** - * Formats a given UTC time string into a more readable format and appends "(UTC)" to indicate the time zone explicitly. - * It converts the time string from "E, dd LLL. yyyy HH:mm:ss" format to "yyyy-LL-dd H:mm" format. - * @param utcTimeString - time string in UTC to be formatted - * @returns Formatted time string with appended "(UTC)" - */ -export const formatUtcTimestamp = (utcTimeString?: UtcTimestamp): string => - utcTimeString - ? format( - parse(utcTimeString, "E, dd LLL. yyyy HH:mm:ss", new Date()), - "yyyy-LL-dd H:mm" - ) + " (UTC)" - : ""; + try { + const utcTime = `${formatInTimeZone( + parseUtcDatetime(utcTimeString), + "UTC", + DATETIME_DISPLAY_FORMAT, + { addSuffix: true } + )} (UTC)` as const; + return utcTime; + } catch (error) { + return "Never"; + } +}; diff --git a/src/testing/factories/discovery.ts b/src/testing/factories/discovery.ts index 82dba7bd46f..73a94f77504 100644 --- a/src/testing/factories/discovery.ts +++ b/src/testing/factories/discovery.ts @@ -1,5 +1,6 @@ import { extend, random } from "cooky-cutter"; +import { timestamp } from "./general"; import { model } from "./model"; import type { Discovery } from "@/app/store/discovery/types"; @@ -13,7 +14,7 @@ export const discovery = extend(model, { hostname: "discovery-hostname", ip: "192.168.1.1", is_external_dhcp: false, - last_seen: "Wed, 08 Jul. 2020 05:35:4", + last_seen: () => timestamp("Wed, 08 Jul. 2020 05:35:4"), mac_address: "00:00:00:00:00:00", mac_organization: "Business Corp, Inc.", mdns: 2, diff --git a/src/testing/factories/general.ts b/src/testing/factories/general.ts index 5c1acaf7c92..0f6af4551a9 100644 --- a/src/testing/factories/general.ts +++ b/src/testing/factories/general.ts @@ -29,7 +29,7 @@ import { PowerFieldScope, PowerFieldType, } from "@/app/store/general/types"; -import type { UtcTimestamp } from "@/app/store/types/model"; +import type { UtcDatetime } from "@/app/store/types/model"; import { NodeActions } from "@/app/store/types/node"; export const architecture = define("amd64"); @@ -129,4 +129,6 @@ export const tlsCertificate = define({ export const version = define("test version"); export const timestamp = (timestamp: string) => - (timestamp as UtcTimestamp) || ("Wed, 08 Jul. 2020 05:35:4" as UtcTimestamp); + timestamp !== undefined + ? (timestamp as UtcDatetime) + : ("Wed, 08 Jul. 2020 05:35:4" as UtcDatetime); diff --git a/yarn.lock b/yarn.lock index 62b91a7db10..2027f6fc43f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6848,6 +6848,11 @@ data-urls@^5.0.0: whatwg-mimetype "^4.0.0" whatwg-url "^14.0.0" +date-fns-tz@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/date-fns-tz/-/date-fns-tz-2.0.1.tgz#0a9b2099031c0d74120b45de9fd23192e48ea495" + integrity sha512-fJCG3Pwx8HUoLhkepdsP7Z5RsucUi+ZBOxyM5d0ZZ6c4SdYustq0VMmOu6Wf7bli+yS/Jwp91TOCqn9jMcVrUA== + date-fns@2.30.0, date-fns@^2.30.0: version "2.30.0" resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.30.0.tgz#f367e644839ff57894ec6ac480de40cae4b0f4d0" @@ -12461,6 +12466,7 @@ string-natural-compare@^3.0.1: integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== "string-width-cjs@npm:string-width@^4.2.0", string-width@^4.2.3: + name string-width-cjs version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -12544,6 +12550,7 @@ string_decoder@~1.1.1: safe-buffer "~5.1.0" "strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.1: + name strip-ansi-cjs version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -13735,6 +13742,7 @@ wordwrap@^1.0.0: integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: + name wrap-ansi-cjs version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==