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]
+ )
+}