diff --git a/packages/utils/package.json b/packages/utils/package.json index 2dc85cda7fb..286ec8ced7b 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -15,6 +15,7 @@ "scripts": { "build": "tsdown", "dev": "tsdown --watch --no-clean", + "test": "vitest run", "check:lint": "oxlint --max-warnings=38 .", "check:types": "tsc --noEmit", "check:format": "oxfmt --check .", @@ -53,6 +54,7 @@ "@types/react": "catalog:", "@types/sanitize-html": "catalog:", "tsdown": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/utils/src/datetime.ts b/packages/utils/src/datetime.ts index 67e809b58df..f7d335ad0a0 100644 --- a/packages/utils/src/datetime.ts +++ b/packages/utils/src/datetime.ts @@ -165,55 +165,46 @@ export const findHowManyDaysLeft = ( /** * @returns {string} formatted date in the form of amount of time passed since the event happened * @description Returns time passed since the event happened - * @param {string | Date} time + * @param {string | number | Date | null | undefined} time * @example calculateTimeAgo("2023-01-01") // 1 year ago */ -export const calculateTimeAgo = (time: string | number | Date | null): string => { +export const calculateTimeAgo = (time: string | number | Date | null | undefined): string => { if (!time) return ""; - // Parse the time to check if it is valid - const parsedTime = typeof time === "string" || typeof time === "number" ? parseISO(String(time)) : time; - // return if undefined - if (!parsedTime) return ""; // Return empty string for invalid dates - // Format the time in the form of amount of time passed since the event happened - const distance = formatDistanceToNow(parsedTime, { addSuffix: true }); - return distance; -}; - -export function calculateTimeAgoShort(date: string | number | Date | null): string { - if (!date) { + try { + const parsedTime = typeof time === "number" ? new Date(time) : typeof time === "string" ? parseISO(time) : time; + if (!isValid(parsedTime)) return ""; + return formatDistanceToNow(parsedTime, { addSuffix: true }); + } catch { return ""; } +}; - const parsedDate = typeof date === "string" ? parseISO(date) : new Date(date); - const now = new Date(); - const diffInSeconds = (now.getTime() - parsedDate.getTime()) / 1000; - - if (diffInSeconds < 60) { - return `${Math.floor(diffInSeconds)}s`; - } - - const diffInMinutes = diffInSeconds / 60; - if (diffInMinutes < 60) { - return `${Math.floor(diffInMinutes)}m`; - } - - const diffInHours = diffInMinutes / 60; - if (diffInHours < 24) { - return `${Math.floor(diffInHours)}h`; - } - - const diffInDays = diffInHours / 24; - if (diffInDays < 30) { - return `${Math.floor(diffInDays)}d`; - } - - const diffInMonths = diffInDays / 30; - if (diffInMonths < 12) { - return `${Math.floor(diffInMonths)}mo`; +/** + * @returns {string} short formatted elapsed time + * @description Returns compact relative time string (e.g. 5m, 2h, 3d, 1y) + * @param {string | number | Date | null | undefined} date + */ +export function calculateTimeAgoShort(date: string | number | Date | null | undefined): string { + if (!date) return ""; + try { + const parsedDate = typeof date === "number" ? new Date(date) : typeof date === "string" ? parseISO(date) : date; + if (!isValid(parsedDate)) return ""; + + const diffInSeconds = (Date.now() - parsedDate.getTime()) / 1000; + if (diffInSeconds < 0) return "0s"; + if (diffInSeconds < 60) return `${Math.floor(diffInSeconds)}s`; + const diffInMinutes = diffInSeconds / 60; + if (diffInMinutes < 60) return `${Math.floor(diffInMinutes)}m`; + const diffInHours = diffInMinutes / 60; + if (diffInHours < 24) return `${Math.floor(diffInHours)}h`; + const diffInDays = diffInHours / 24; + if (diffInDays < 30) return `${Math.floor(diffInDays)}d`; + const diffInMonths = diffInDays / 30; + if (diffInMonths < 12) return `${Math.floor(diffInMonths)}mo`; + return `${Math.floor(diffInMonths / 12)}y`; + } catch { + return ""; } - - const diffInYears = diffInMonths / 12; - return `${Math.floor(diffInYears)}y`; } // Date Validation Helpers @@ -284,7 +275,7 @@ export const getDate = (date: string | Date | undefined | null): Date | undefine try { if (!date || date === "") return; - if (typeof date !== "string" && !(date instanceof String)) return date; + if (typeof date !== "string") return date; const [yearString, monthString, dayString] = date.substring(0, 10).split("-"); const year = parseInt(yearString); @@ -331,9 +322,22 @@ export const convertToEpoch = (dateString: string | undefined) => { * get current Date time in UTC ISO format * @returns */ -export const getCurrentDateTimeInISO = () => { - const date = new Date(); - return date.toISOString(); +export const getCurrentDateInUTC = () => new Date().toISOString(); + +/** + * @description calculates the difference in hours between two dates + * @param startDate + * @param endDate + * @returns + */ +export const calculateDifferenceInHours = (startDate: string | Date, endDate: string | Date): number => { + const parsedStartDate = new Date(startDate); + const parsedEndDate = new Date(endDate); + + const diffInMs = parsedEndDate.getTime() - parsedStartDate.getTime(); + const diffInHours = diffInMs / (1000 * 60 * 60); + + return Math.round(diffInHours); }; /** @@ -390,23 +394,16 @@ export const getReadTimeFromWordsCount = (wordsCount: number): number => { * @returns */ export const generateDateArray = (startDate: string | Date, endDate: string | Date) => { - // Convert the start and end dates to Date objects if they aren't already const start = new Date(startDate); - // start.setDate(start.getDate() + 1); const end = new Date(endDate); end.setDate(end.getDate() + 2); - // Create an empty array to store the dates const dateArray = []; - // Use a while loop to generate dates between the range - while (start <= end) { - // Push the current date (converted to ISO string for consistency) + for (let current = new Date(start); current <= end; current = new Date(current.setDate(current.getDate() + 1))) { dateArray.push({ - date: new Date(start).toISOString().split("T")[0], + date: new Date(current).toISOString().split("T")[0], }); - // Increment the date by 1 day (86400000 milliseconds) - start.setDate(start.getDate() + 1); } return dateArray; diff --git a/packages/utils/tests/datetime.test.ts b/packages/utils/tests/datetime.test.ts new file mode 100644 index 00000000000..9e497ffc0e5 --- /dev/null +++ b/packages/utils/tests/datetime.test.ts @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { describe, expect, it } from "vitest"; +import { calculateTimeAgo, calculateTimeAgoShort } from "../src/datetime"; + +describe("calculateTimeAgo and calculateTimeAgoShort", () => { + describe("calculateTimeAgo", () => { + it("should format Date instances correctly", () => { + const oneHourAgo = new Date(Date.now() - 3600 * 1000); + expect(calculateTimeAgo(oneHourAgo)).toMatch(/about 1 hour ago|1 hour ago/); + }); + + it("should format numeric millisecond timestamps without throwing RangeError", () => { + const oneMinuteAgo = Date.now() - 60 * 1000; + expect(calculateTimeAgo(oneMinuteAgo)).toMatch(/1 minute ago|minute ago/); + }); + + it("should format ISO strings without throwing", () => { + const oneDayAgo = new Date(Date.now() - 24 * 3600 * 1000).toISOString(); + expect(calculateTimeAgo(oneDayAgo)).toMatch(/1 day ago|yesterday/); + }); + + it("should safely return empty string for null, undefined, or invalid inputs", () => { + expect(calculateTimeAgo(null)).toBe(""); + expect(calculateTimeAgo(undefined)).toBe(""); + expect(calculateTimeAgo("")).toBe(""); + expect(calculateTimeAgo("invalid-date-string")).toBe(""); + expect(calculateTimeAgo(NaN)).toBe(""); + }); + }); + + describe("calculateTimeAgoShort", () => { + it("should format elapsed time into compact units", () => { + const now = Date.now(); + expect(calculateTimeAgoShort(now - 30 * 1000)).toMatch(/^(29|30|31)s$/); + expect(calculateTimeAgoShort(now - 5 * 60 * 1000)).toBe("5m"); + expect(calculateTimeAgoShort(now - 3 * 3600 * 1000)).toBe("3h"); + expect(calculateTimeAgoShort(now - 4 * 24 * 3600 * 1000)).toBe("4d"); + expect(calculateTimeAgoShort(now - 60 * 24 * 3600 * 1000)).toBe("2mo"); + expect(calculateTimeAgoShort(now - 750 * 24 * 3600 * 1000)).toBe("2y"); + }); + + it("should return 0s for future dates", () => { + expect(calculateTimeAgoShort(Date.now() + 60 * 1000)).toBe("0s"); + }); + + it("should safely return empty string for invalid inputs", () => { + expect(calculateTimeAgoShort(null)).toBe(""); + expect(calculateTimeAgoShort(undefined)).toBe(""); + expect(calculateTimeAgoShort("")).toBe(""); + expect(calculateTimeAgoShort("invalid-date")).toBe(""); + expect(calculateTimeAgoShort(NaN)).toBe(""); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 715e85e9129..794f22bc206 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1963,6 +1963,9 @@ importers: typescript: specifier: 5.8.3 version: 5.8.3 + vitest: + specifier: 'catalog:' + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.12.0)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@22.12.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.43.1)(tsx@4.20.6)(yaml@2.8.3)) packages: