diff --git a/runtime/src/index.ts b/runtime/src/index.ts index a3f580d9..ae1185e2 100644 --- a/runtime/src/index.ts +++ b/runtime/src/index.ts @@ -9,7 +9,7 @@ export { useDataEngine, } from '@dhis2/app-service-data' -export { useConfig } from '@dhis2/app-service-config' +export { useConfig, useTimeZoneConversion } from '@dhis2/app-service-config' export { useAlerts, useAlert } from '@dhis2/app-service-alerts' diff --git a/services/config/package.json b/services/config/package.json index 505a9be4..ee4b71ad 100644 --- a/services/config/package.json +++ b/services/config/package.json @@ -34,7 +34,7 @@ "watch": "NODE_ENV=development concurrently -n build,types \"yarn build:package --watch\" \"yarn build:types --watch\"", "type-check": "tsc --noEmit --allowJs --checkJs", "type-check:watch": "yarn type-check --watch", - "test": "d2-app-scripts test", + "test": "TZ=Etc/UTC d2-app-scripts test", "coverage": "yarn test --coverage" } } diff --git a/services/config/src/__tests__/useTimeZoneConversion.test.tsx b/services/config/src/__tests__/useTimeZoneConversion.test.tsx new file mode 100644 index 00000000..0a209aee --- /dev/null +++ b/services/config/src/__tests__/useTimeZoneConversion.test.tsx @@ -0,0 +1,153 @@ +import { renderHook } from '@testing-library/react-hooks' +import React, { ReactNode } from 'react' +import { ConfigProvider, useTimeZoneConversion } from '../index' + +const defaultConfig = { baseUrl: '/', apiVersion: 40 } +const defaultSystemInfo = { + version: '40', + contextPath: '', + serverTimeZoneId: 'UTC', +} + +// tests are set to run at UTC when running yarn test + +describe('useTimeZoneConversion', () => { + it('Hook returns a fromClientDate and fromServerDate function', () => { + const config = { baseUrl: '/', apiVersion: 30 } + const wrapper = ({ children }: { children?: ReactNode }) => ( + {children} + ) + const { result } = renderHook(() => useTimeZoneConversion(), { + wrapper, + }) + + expect(result.current).toHaveProperty('fromClientDate') + expect(typeof result.current.fromClientDate).toBe('function') + expect(result.current).toHaveProperty('fromServerDate') + expect(typeof result.current.fromServerDate).toBe('function') + }) + + it('returns fromServerDate that corrects for server time zone', () => { + const systemInfo = { + ...defaultSystemInfo, + serverTimeZoneId: 'Europe/Oslo', + } + const config = { ...defaultConfig, systemInfo } + const wrapper = ({ children }: { children?: ReactNode }) => ( + {children} + ) + const { result } = renderHook(() => useTimeZoneConversion(), { + wrapper, + }) + + const serverDate = result.current.fromServerDate('2010-01-01') + const expectedDateString = '2009-12-31T23:00:00.000' + expect(serverDate.getClientZonedISOString()).toBe(expectedDateString) + }) + + // fromServerDate accepts number, valid date string, or date object + it('returns fromServerDate which accepts number, valid date string, or date object', () => { + const config = { ...defaultConfig, systemInfo: defaultSystemInfo } + const wrapper = ({ children }: { children?: ReactNode }) => ( + {children} + ) + const { result } = renderHook(() => useTimeZoneConversion(), { + wrapper, + }) + + const dateString = '2010-01-01' + const dateFromString = new Date('2010-01-01') + const millisecondsAfterUTC = dateFromString.getTime() + + const serverDateFromString = result.current.fromServerDate(dateString) + const serverDateFromDate = result.current.fromServerDate(dateFromString) + const serverDateFromNumber = + result.current.fromServerDate(millisecondsAfterUTC) + + expect(serverDateFromString).toEqual(serverDateFromDate) + expect(serverDateFromString).toEqual(serverDateFromNumber) + }) + + // returns current (client) date if no argument is provided + it('returns fromServerDate which returns current timestamp if no argument is passed', () => { + const config = { ...defaultConfig, systemInfo: defaultSystemInfo } + const wrapper = ({ children }: { children?: ReactNode }) => ( + {children} + ) + const { result } = renderHook(() => useTimeZoneConversion(), { + wrapper, + }) + + // if no date-like is passed to fromSeverDate, Date.now() is used to initialize date + jest.spyOn(global.Date, 'now').mockImplementation(() => + new Date('2020-10-15T12:00:00.000Z').valueOf() + ) + + const timeFromHook = result.current.fromServerDate() + + expect(timeFromHook).toEqual(new Date('2020-10-15T12:00:00.000Z')) + }) + + // fromServerDate defaults to client time zone if invalid server time zone provided + it('returns fromServerDate that assumes no time zone difference if provided time zone is invalid', () => { + const systemInfo = { + ...defaultSystemInfo, + serverTimeZoneId: 'Asia/Oslo', + } + const config = { ...defaultConfig, systemInfo } + const wrapper = ({ children }: { children?: ReactNode }) => ( + {children} + ) + const { result } = renderHook(() => useTimeZoneConversion(), { + wrapper, + }) + + const serverDate = result.current.fromServerDate('2010-01-01') + const expectedDateString = '2010-01-01T00:00:00.000' + expect(serverDate.getClientZonedISOString()).toBe(expectedDateString) + }) + + it('returns fromServerDate with server date that matches passed time regardless of timezone', () => { + const systemInfo = { + ...defaultSystemInfo, + serverTimeZoneId: 'Asia/Jakarta', + } + const config = { ...defaultConfig, systemInfo } + const wrapper = ({ children }: { children?: ReactNode }) => ( + {children} + ) + const { result } = renderHook(() => useTimeZoneConversion(), { + wrapper, + }) + + const serverDate = result.current.fromServerDate('2015-03-03T12:00:00') + const expectedDateString = '2015-03-03T12:00:00.000' + expect(serverDate.getServerZonedISOString()).toBe(expectedDateString) + }) + + it('returns fromClientDate that reflects client time but makes server time string accessible', () => { + const systemInfo = { + ...defaultSystemInfo, + serverTimeZoneId: 'America/Guatemala', + } + const config = { ...defaultConfig, systemInfo } + const wrapper = ({ children }: { children?: ReactNode }) => ( + {children} + ) + const { result } = renderHook(() => useTimeZoneConversion(), { + wrapper, + }) + + const serverDate = result.current.fromClientDate('2018-08-15T12:00:00') + const expectedClientDateString = '2018-08-15T12:00:00.000' + const expectedServerDateString = '2018-08-15T06:00:00.000' + const javascriptDate = new Date('2018-08-15T12:00:00') + expect(serverDate.getClientZonedISOString()).toBe( + expectedClientDateString + ) + expect(serverDate.getServerZonedISOString()).toBe( + expectedServerDateString + ) + expect(serverDate.getTime()).toEqual(javascriptDate.getTime()) + }) +}) diff --git a/services/config/src/index.ts b/services/config/src/index.ts index 790d6ba1..dc132ee9 100644 --- a/services/config/src/index.ts +++ b/services/config/src/index.ts @@ -1,4 +1,5 @@ export { useConfig } from './useConfig' +export { useTimeZoneConversion } from './useTimeZoneConversion' export { ConfigProvider } from './ConfigProvider' export type { Config } from './types' diff --git a/services/config/src/types.ts b/services/config/src/types.ts index f4fd397d..6391e812 100644 --- a/services/config/src/types.ts +++ b/services/config/src/types.ts @@ -6,9 +6,12 @@ type Version = { tag?: string } +export type DateInput = string | Date | number | null + interface SystemInfo { version: string contextPath: string + serverTimeZoneId: string } export interface Config { diff --git a/services/config/src/useTimeZoneConversion.ts b/services/config/src/useTimeZoneConversion.ts new file mode 100644 index 00000000..77aa0978 --- /dev/null +++ b/services/config/src/useTimeZoneConversion.ts @@ -0,0 +1,136 @@ +import { useCallback, useMemo } from 'react' +import { DateInput } from './types' +import { useConfig } from './useConfig' + +// extend date with extra methods +class DHIS2Date extends Date { + serverOffset: number + serverTimezone: string + clientTimezone: string + + constructor({ + date, + serverOffset, + serverTimezone, + clientTimezone, + }: { + date: DateInput + serverOffset: number + serverTimezone: string + clientTimezone: string + }) { + if (date) { + super(date) + } else { + super(Date.now()) + } + this.serverOffset = serverOffset + this.serverTimezone = serverTimezone + this.clientTimezone = clientTimezone + } + + private _getISOString(date: Date): string { + const year = date.getFullYear().toString().padStart(4, '0') + const month = (date.getMonth() + 1).toString().padStart(2, '0') + const days = date.getDate().toString().padStart(2, '0') + const hours = date.getHours().toString().padStart(2, '0') + const minutes = date.getMinutes().toString().padStart(2, '0') + const seconds = date.getSeconds().toString().padStart(2, '0') + const milliseconds = date.getMilliseconds().toString().padStart(3, '0') + return `${year}-${month}-${days}T${hours}:${minutes}:${seconds}.${milliseconds}` + } + + public getServerZonedISOString(): string { + const serverDate = new Date(this.getTime() - this.serverOffset) + return this._getISOString(serverDate) + } + + public getClientZonedISOString(): string { + return this._getISOString(this) + } +} + +const useServerTimeOffset = (serverTimezone: string): number => { + return useMemo(() => { + try { + const nowClientTime = new Date() + nowClientTime.setMilliseconds(0) + + // 'sv' is used for localeString because it is the closest to ISO format + // in principle, any locale should be parsable back to a date, but we encountered an error + // when using en-US in certain environments, which we could not replicate when using 'sv' + // Converting to localeString and then back to date is unfortunately the only current way + // to construct a date that accounts for timezone. + const serverLocaleString = nowClientTime.toLocaleString('sv', { + timeZone: serverTimezone, + }) + const nowServerTimeZone = new Date(serverLocaleString) + nowServerTimeZone.setMilliseconds(0) + + return nowClientTime.getTime() - nowServerTimeZone.getTime() + } catch (err) { + console.error( + 'Server time offset could not be determined; assuming no client/server difference', + err + ) + // if date is not constructable with timezone, assume 0 difference between client/server + return 0 + } + }, [serverTimezone]) +} + +export const useTimeZoneConversion = (): { + fromServerDate: (date?: DateInput) => DHIS2Date + fromClientDate: (date?: DateInput) => DHIS2Date +} => { + const { systemInfo } = useConfig() + let serverTimezone: string + const clientTimezone: string = + Intl.DateTimeFormat().resolvedOptions().timeZone + + if (systemInfo?.serverTimeZoneId) { + serverTimezone = systemInfo.serverTimeZoneId + } else { + // Fallback to client timezone + serverTimezone = clientTimezone + console.warn( + 'No server timezone ID found, falling back to client timezone. This could cause date conversion issues.' + ) + } + + const serverOffset = useServerTimeOffset(serverTimezone) + + const fromServerDate = useCallback( + (date) => { + const serverDate = new Date(date) + const clientDate = new DHIS2Date({ + date: serverDate.getTime() + serverOffset, + serverOffset, + serverTimezone, + clientTimezone, + }) + + return clientDate + }, + [serverOffset, serverTimezone, clientTimezone] + ) + + const fromClientDate = useCallback( + (date) => { + const clientDate = new DHIS2Date({ + date, + serverOffset, + serverTimezone, + clientTimezone, + }) + + return clientDate + }, + [serverOffset, serverTimezone, clientTimezone] + ) + + return useMemo( + () => ({ fromServerDate, fromClientDate }), + [fromServerDate, fromClientDate] + ) +}