diff --git a/frontend/src/components/Table/Formatter/DateFormatter.test.tsx b/frontend/src/components/Table/Formatter/DateFormatter.test.tsx new file mode 100644 index 00000000..33c561c7 --- /dev/null +++ b/frontend/src/components/Table/Formatter/DateFormatter.test.tsx @@ -0,0 +1,77 @@ +import { DateTimeFormat } from "src/locale"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +describe("DateFormatter", () => { + // Keep a reference to the real Intl to restore later + const RealIntl = global.Intl; + const desiredTimeZone = "Europe/London"; + const desiredLocale = "en-GB"; + + beforeAll(() => { + // Ensure Node-based libs using TZ behave deterministically + try { + process.env.TZ = desiredTimeZone; + } catch { + // ignore if not available + } + + // Mock Intl.DateTimeFormat so formatting is stable regardless of host + const MockedDateTimeFormat = class extends RealIntl.DateTimeFormat { + constructor( + _locales?: string | string[], + options?: Intl.DateTimeFormatOptions, + ) { + super(desiredLocale, { + ...options, + timeZone: desiredTimeZone, + }); + } + } as unknown as typeof Intl.DateTimeFormat; + + global.Intl = { + ...RealIntl, + DateTimeFormat: MockedDateTimeFormat, + }; + }); + + afterAll(() => { + // Restore original Intl after tests + global.Intl = RealIntl; + }); + + it("format date from iso date", () => { + const value = "2024-01-01T00:00:00.000Z"; + const text = DateTimeFormat(value); + expect(text).toBe("Monday, 01/01/2024, 12:00:00 am"); + }); + + it("format date from unix timestamp number", () => { + const value = 1762476112; + const text = DateTimeFormat(value); + expect(text).toBe("Friday, 07/11/2025, 12:41:52 am"); + }); + + it("format date from unix timestamp string", () => { + const value = "1762476112"; + const text = DateTimeFormat(value); + expect(text).toBe("Friday, 07/11/2025, 12:41:52 am"); + }); + + it("catch bad format from string", () => { + const value = "this is not a good date"; + const text = DateTimeFormat(value); + expect(text).toBe("this is not a good date"); + }); + + it("catch bad format from number", () => { + const value = -100; + const text = DateTimeFormat(value); + expect(text).toBe("-100"); + }); + + it("catch bad format from number as string", () => { + const value = "-100"; + const text = DateTimeFormat(value); + expect(text).toBe("-100"); + }); +}); diff --git a/frontend/src/locale/DateTimeFormat.ts b/frontend/src/locale/DateTimeFormat.ts index fb8e66c8..93c2121d 100644 --- a/frontend/src/locale/DateTimeFormat.ts +++ b/frontend/src/locale/DateTimeFormat.ts @@ -1,15 +1,36 @@ -import { intlFormat, parseISO } from "date-fns"; +import { fromUnixTime, intlFormat, parseISO } from "date-fns"; -const DateTimeFormat = (isoDate: string) => - intlFormat(parseISO(isoDate), { - weekday: "long", - year: "numeric", - month: "numeric", - day: "numeric", - hour: "numeric", - minute: "numeric", - second: "numeric", - hour12: true, - }); +const isUnixTimestamp = (value: unknown): boolean => { + if (typeof value !== "number" && typeof value !== "string") return false; + const num = Number(value); + if (!Number.isFinite(num)) return false; + // Check plausible Unix timestamp range: from 1970 to ~year 3000 + // Support both seconds and milliseconds + if (num > 0 && num < 10000000000) return true; // seconds (<= 10 digits) + if (num >= 10000000000 && num < 32503680000000) return true; // milliseconds (<= 13 digits) + return false; +}; + +const DateTimeFormat = (value: string | number): string => { + if (typeof value !== "number" && typeof value !== "string") return `${value}`; + + try { + const d = isUnixTimestamp(value) + ? fromUnixTime(+value) + : parseISO(`${value}`); + return intlFormat(d, { + weekday: "long", + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + second: "numeric", + hour12: true, + }); + } catch { + return `${value}`; + } +}; export { DateTimeFormat }; diff --git a/frontend/src/locale/IntlProvider.tsx b/frontend/src/locale/IntlProvider.tsx index 46467d01..c2779908 100644 --- a/frontend/src/locale/IntlProvider.tsx +++ b/frontend/src/locale/IntlProvider.tsx @@ -30,13 +30,20 @@ const getLocale = (short = false) => { if (short) { return loc.slice(0, 2); } + // finally, fallback + if (!loc) { + loc = "en"; + } return loc; }; const cache = createIntlCache(); const initialMessages = loadMessages(getLocale()); -let intl = createIntl({ locale: getLocale(), messages: initialMessages }, cache); +let intl = createIntl( + { locale: getLocale(), messages: initialMessages }, + cache, +); const changeLocale = (locale: string): void => { const messages = loadMessages(locale); @@ -76,4 +83,12 @@ const T = ({ ); }; -export { localeOptions, getFlagCodeForLocale, getLocale, createIntl, changeLocale, intl, T }; +export { + localeOptions, + getFlagCodeForLocale, + getLocale, + createIntl, + changeLocale, + intl, + T, +}; diff --git a/scripts/ci/frontend-build b/scripts/ci/frontend-build index 64e26e50..1cfc5116 100755 --- a/scripts/ci/frontend-build +++ b/scripts/ci/frontend-build @@ -16,7 +16,7 @@ if hash docker 2>/dev/null; then -e NODE_OPTIONS=--openssl-legacy-provider \ -v "$(pwd)/frontend:/app/frontend" \ -w /app/frontend "${DOCKER_IMAGE}" \ - sh -c "yarn install && yarn lint && yarn build && chown -R $(id -u):$(id -g) /app/frontend" + sh -c "yarn install && yarn lint && yarn vitest run && yarn build && chown -R $(id -u):$(id -g) /app/frontend" echo -e "${BLUE}❯ ${GREEN}Building Frontend Complete${RESET}" else