From 4e1c5e27d331ca149e6376f39ccbcce4d906fbce Mon Sep 17 00:00:00 2001 From: Dylan Trachsel Date: Mon, 15 Jul 2024 14:47:22 -0700 Subject: [PATCH] feat: add timezone converter util --- package-lock.json | 31 ++++++++++++++----- packages/react-components/package.json | 3 +- .../useSiteWiseAnomalyDataSource/constants.ts | 2 +- .../react-components/src/utils/time.spec.ts | 24 ++++++++++++++ packages/react-components/src/utils/time.ts | 22 +++++++++++++ 5 files changed, 72 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index f9739ebcd5..091ddf144e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42475,6 +42475,14 @@ "url": "https://opencollective.com/date-fns" } }, + "node_modules/date-fns-tz": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.1.3.tgz", + "integrity": "sha512-ZfbMu+nbzW0mEzC8VZrLiSWvUIaI3aRHeq33mTe7Y38UctKukgqPR4nTDwcwS4d64Gf8GghnVsroBuMY3eiTeA==", + "peerDependencies": { + "date-fns": "^3.0.0" + } + }, "node_modules/debounce": { "version": "1.2.1", "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", @@ -75243,7 +75251,8 @@ "d3-array": "^3.2.3", "d3-format": "^3.1.0", "d3-shape": "^3.2.0", - "date-fns": "^3.4.0", + "date-fns": "^3.6.0", + "date-fns-tz": "^3.1.3", "dompurify": "3.0.5", "echarts": "^5.4.3", "is-hotkey": "^0.2.0", @@ -75851,9 +75860,9 @@ } }, "packages/react-components/node_modules/date-fns": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.4.0.tgz", - "integrity": "sha512-Akz4R8J9MXBsOgF1QeWeCsbv6pntT5KCPjU0Q9prBxVmWJYPLhwAIsNg3b0QAdr0ttiozYLD3L/af7Ra0jqYXw==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -94371,7 +94380,8 @@ "d3-array": "^3.2.3", "d3-format": "^3.1.0", "d3-shape": "^3.2.0", - "date-fns": "^3.4.0", + "date-fns": "^3.6.0", + "date-fns-tz": "^3.1.3", "dompurify": "3.0.5", "echarts": "^5.4.3", "eslint-config-iot-app-kit": "10.9.0", @@ -94784,9 +94794,9 @@ } }, "date-fns": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.4.0.tgz", - "integrity": "sha512-Akz4R8J9MXBsOgF1QeWeCsbv6pntT5KCPjU0Q9prBxVmWJYPLhwAIsNg3b0QAdr0ttiozYLD3L/af7Ra0jqYXw==" + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==" }, "domexception": { "version": "4.0.0", @@ -119118,6 +119128,11 @@ "version": "2.29.2", "integrity": "sha512-0VNbwmWJDS/G3ySwFSJA3ayhbURMTJLtwM2DTxf9CWondCnh6DTNlO9JgRSq6ibf4eD0lfMJNBxUdEAHHix+bA==" }, + "date-fns-tz": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.1.3.tgz", + "integrity": "sha512-ZfbMu+nbzW0mEzC8VZrLiSWvUIaI3aRHeq33mTe7Y38UctKukgqPR4nTDwcwS4d64Gf8GghnVsroBuMY3eiTeA==" + }, "debounce": { "version": "1.2.1", "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==" diff --git a/packages/react-components/package.json b/packages/react-components/package.json index d2dd98c111..212b6fcdeb 100644 --- a/packages/react-components/package.json +++ b/packages/react-components/package.json @@ -124,7 +124,8 @@ "d3-array": "^3.2.3", "d3-format": "^3.1.0", "d3-shape": "^3.2.0", - "date-fns": "^3.4.0", + "date-fns": "^3.6.0", + "date-fns-tz": "^3.1.3", "dompurify": "3.0.5", "echarts": "^5.4.3", "is-hotkey": "^0.2.0", diff --git a/packages/react-components/src/queries/useSiteWiseAnomalyDataSource/constants.ts b/packages/react-components/src/queries/useSiteWiseAnomalyDataSource/constants.ts index 2d4e8fa9cb..19be163ec3 100644 --- a/packages/react-components/src/queries/useSiteWiseAnomalyDataSource/constants.ts +++ b/packages/react-components/src/queries/useSiteWiseAnomalyDataSource/constants.ts @@ -1,5 +1,5 @@ import { HistoricalViewport } from '@iot-app-kit/core'; -import { sub } from 'date-fns/sub'; +import { sub } from 'date-fns'; export const DEFAULT_ANOMALY_DATA_SOURCE_VIEWPORT: HistoricalViewport = { start: sub(Date.now(), { days: 7 }), diff --git a/packages/react-components/src/utils/time.spec.ts b/packages/react-components/src/utils/time.spec.ts index f831d2314a..b52a5191ec 100644 --- a/packages/react-components/src/utils/time.spec.ts +++ b/packages/react-components/src/utils/time.spec.ts @@ -8,6 +8,7 @@ import { SECOND_IN_MS, toTimestamp, YEAR_IN_MS, + formatDate } from './time'; describe('convert from milliseconds', () => { @@ -285,3 +286,26 @@ describe('toTimestamp', () => { ).toBe(0); }); }); + +describe('formatDate', () => { + it('correctly converts to a different timezone', () => { + const date = new Date(0).getTime(); + + const formattedDate = formatDate(date, { timeZone: 'America/Denver' }); + + // UTC-7 + expect(formattedDate).toBe('1969-12-31, 05:00:00 p.m.'); + + const formattedDate2 = formatDate(date, { timeZone: 'Asia/Tokyo' }); + + // UTC+9 + expect(formattedDate2).toBe('1970-01-01, 09:00:00 a.m.'); + }); + + it('converts date to specified pattern', () => { + const date = new Date(0).getTime(); + + const formattedDate = formatDate(date, { timeZone: 'America/Denver', pattern: 'hh:mm a' }); + expect(formattedDate).toBe('05:00 PM'); + }); +}); diff --git a/packages/react-components/src/utils/time.ts b/packages/react-components/src/utils/time.ts index 8ae5b97866..fbc7f15a8b 100644 --- a/packages/react-components/src/utils/time.ts +++ b/packages/react-components/src/utils/time.ts @@ -1,6 +1,7 @@ import type { TimeInNanos } from '@aws-sdk/client-iotsitewise'; import { NANO_SECOND_IN_MS } from '@iot-app-kit/core'; import parse from 'parse-duration'; +import { toZonedTime, format } from 'date-fns-tz'; export const SECOND_IN_MS = 1000; export const MINUTE_IN_MS = 60 * SECOND_IN_MS; @@ -9,6 +10,7 @@ export const DAY_IN_MS = 24 * HOUR_IN_MS; // Not precisely accurate, only estimates. exact duration depends on start date. use with care. export const MONTH_IN_MS = 30 * DAY_IN_MS; export const YEAR_IN_MS = 12 * MONTH_IN_MS; +export const DEFAULT_DATE_TIME = 'yyy-MM-dd, hh:mm:ss aaaa'; // Global time format strings export const SHORT_TIME = 'hh:mm a'; @@ -166,3 +168,23 @@ export const toTimestamp = (time: TimeInNanos | undefined): number => (time.offsetInNanos || 0) * NANO_SECOND_IN_MS )) || 0; + +// https://date-fns.org/v3.6.0/docs/Time-Zones#date-fns-tz +// converts an epoch date to a formatted string in a specific timeZone +export const formatDate = ( + dateTime: number, + options?: { timeZone?: string, pattern?: string } +) => { + const formatPattern = options?.pattern ?? DEFAULT_DATE_TIME; + + const userTimeZone = options?.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;; + + // Convert epoch time to a zoned date object + const zonedDate = toZonedTime(new Date(dateTime), userTimeZone); + + // Take offset from zoned date to get a date with the days/hours modified based on offset for display + const dateWithOffset = new Date(zonedDate.valueOf() + (zonedDate.getTimezoneOffset() * 60 * 1000)); + + const formattedString = format(dateWithOffset, formatPattern, { timeZone: userTimeZone }); + return formattedString; +};