Skip to content

Commit d511303

Browse files
authored
feat: add hook to adjust for server time [LIBS-396] (#1308)
1 parent 2aea6e2 commit d511303

File tree

6 files changed

+295
-2
lines changed

6 files changed

+295
-2
lines changed

runtime/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export {
99
useDataEngine,
1010
} from '@dhis2/app-service-data'
1111

12-
export { useConfig } from '@dhis2/app-service-config'
12+
export { useConfig, useTimeZoneConversion } from '@dhis2/app-service-config'
1313

1414
export { useAlerts, useAlert } from '@dhis2/app-service-alerts'
1515

services/config/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
"watch": "NODE_ENV=development concurrently -n build,types \"yarn build:package --watch\" \"yarn build:types --watch\"",
3535
"type-check": "tsc --noEmit --allowJs --checkJs",
3636
"type-check:watch": "yarn type-check --watch",
37-
"test": "d2-app-scripts test",
37+
"test": "TZ=Etc/UTC d2-app-scripts test",
3838
"coverage": "yarn test --coverage"
3939
}
4040
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { renderHook } from '@testing-library/react-hooks'
2+
import React, { ReactNode } from 'react'
3+
import { ConfigProvider, useTimeZoneConversion } from '../index'
4+
5+
const defaultConfig = { baseUrl: '/', apiVersion: 40 }
6+
const defaultSystemInfo = {
7+
version: '40',
8+
contextPath: '',
9+
serverTimeZoneId: 'UTC',
10+
}
11+
12+
// tests are set to run at UTC when running yarn test
13+
14+
describe('useTimeZoneConversion', () => {
15+
it('Hook returns a fromClientDate and fromServerDate function', () => {
16+
const config = { baseUrl: '/', apiVersion: 30 }
17+
const wrapper = ({ children }: { children?: ReactNode }) => (
18+
<ConfigProvider config={config}>{children}</ConfigProvider>
19+
)
20+
const { result } = renderHook(() => useTimeZoneConversion(), {
21+
wrapper,
22+
})
23+
24+
expect(result.current).toHaveProperty('fromClientDate')
25+
expect(typeof result.current.fromClientDate).toBe('function')
26+
expect(result.current).toHaveProperty('fromServerDate')
27+
expect(typeof result.current.fromServerDate).toBe('function')
28+
})
29+
30+
it('returns fromServerDate that corrects for server time zone', () => {
31+
const systemInfo = {
32+
...defaultSystemInfo,
33+
serverTimeZoneId: 'Europe/Oslo',
34+
}
35+
const config = { ...defaultConfig, systemInfo }
36+
const wrapper = ({ children }: { children?: ReactNode }) => (
37+
<ConfigProvider config={config}>{children}</ConfigProvider>
38+
)
39+
const { result } = renderHook(() => useTimeZoneConversion(), {
40+
wrapper,
41+
})
42+
43+
const serverDate = result.current.fromServerDate('2010-01-01')
44+
const expectedDateString = '2009-12-31T23:00:00.000'
45+
expect(serverDate.getClientZonedISOString()).toBe(expectedDateString)
46+
})
47+
48+
// fromServerDate accepts number, valid date string, or date object
49+
it('returns fromServerDate which accepts number, valid date string, or date object', () => {
50+
const config = { ...defaultConfig, systemInfo: defaultSystemInfo }
51+
const wrapper = ({ children }: { children?: ReactNode }) => (
52+
<ConfigProvider config={config}>{children}</ConfigProvider>
53+
)
54+
const { result } = renderHook(() => useTimeZoneConversion(), {
55+
wrapper,
56+
})
57+
58+
const dateString = '2010-01-01'
59+
const dateFromString = new Date('2010-01-01')
60+
const millisecondsAfterUTC = dateFromString.getTime()
61+
62+
const serverDateFromString = result.current.fromServerDate(dateString)
63+
const serverDateFromDate = result.current.fromServerDate(dateFromString)
64+
const serverDateFromNumber =
65+
result.current.fromServerDate(millisecondsAfterUTC)
66+
67+
expect(serverDateFromString).toEqual(serverDateFromDate)
68+
expect(serverDateFromString).toEqual(serverDateFromNumber)
69+
})
70+
71+
// returns current (client) date if no argument is provided
72+
it('returns fromServerDate which returns current timestamp if no argument is passed', () => {
73+
const config = { ...defaultConfig, systemInfo: defaultSystemInfo }
74+
const wrapper = ({ children }: { children?: ReactNode }) => (
75+
<ConfigProvider config={config}>{children}</ConfigProvider>
76+
)
77+
const { result } = renderHook(() => useTimeZoneConversion(), {
78+
wrapper,
79+
})
80+
81+
// if no date-like is passed to fromSeverDate, Date.now() is used to initialize date
82+
jest.spyOn(global.Date, 'now').mockImplementation(() =>
83+
new Date('2020-10-15T12:00:00.000Z').valueOf()
84+
)
85+
86+
const timeFromHook = result.current.fromServerDate()
87+
88+
expect(timeFromHook).toEqual(new Date('2020-10-15T12:00:00.000Z'))
89+
})
90+
91+
// fromServerDate defaults to client time zone if invalid server time zone provided
92+
it('returns fromServerDate that assumes no time zone difference if provided time zone is invalid', () => {
93+
const systemInfo = {
94+
...defaultSystemInfo,
95+
serverTimeZoneId: 'Asia/Oslo',
96+
}
97+
const config = { ...defaultConfig, systemInfo }
98+
const wrapper = ({ children }: { children?: ReactNode }) => (
99+
<ConfigProvider config={config}>{children}</ConfigProvider>
100+
)
101+
const { result } = renderHook(() => useTimeZoneConversion(), {
102+
wrapper,
103+
})
104+
105+
const serverDate = result.current.fromServerDate('2010-01-01')
106+
const expectedDateString = '2010-01-01T00:00:00.000'
107+
expect(serverDate.getClientZonedISOString()).toBe(expectedDateString)
108+
})
109+
110+
it('returns fromServerDate with server date that matches passed time regardless of timezone', () => {
111+
const systemInfo = {
112+
...defaultSystemInfo,
113+
serverTimeZoneId: 'Asia/Jakarta',
114+
}
115+
const config = { ...defaultConfig, systemInfo }
116+
const wrapper = ({ children }: { children?: ReactNode }) => (
117+
<ConfigProvider config={config}>{children}</ConfigProvider>
118+
)
119+
const { result } = renderHook(() => useTimeZoneConversion(), {
120+
wrapper,
121+
})
122+
123+
const serverDate = result.current.fromServerDate('2015-03-03T12:00:00')
124+
const expectedDateString = '2015-03-03T12:00:00.000'
125+
expect(serverDate.getServerZonedISOString()).toBe(expectedDateString)
126+
})
127+
128+
it('returns fromClientDate that reflects client time but makes server time string accessible', () => {
129+
const systemInfo = {
130+
...defaultSystemInfo,
131+
serverTimeZoneId: 'America/Guatemala',
132+
}
133+
const config = { ...defaultConfig, systemInfo }
134+
const wrapper = ({ children }: { children?: ReactNode }) => (
135+
<ConfigProvider config={config}>{children}</ConfigProvider>
136+
)
137+
const { result } = renderHook(() => useTimeZoneConversion(), {
138+
wrapper,
139+
})
140+
141+
const serverDate = result.current.fromClientDate('2018-08-15T12:00:00')
142+
const expectedClientDateString = '2018-08-15T12:00:00.000'
143+
const expectedServerDateString = '2018-08-15T06:00:00.000'
144+
const javascriptDate = new Date('2018-08-15T12:00:00')
145+
expect(serverDate.getClientZonedISOString()).toBe(
146+
expectedClientDateString
147+
)
148+
expect(serverDate.getServerZonedISOString()).toBe(
149+
expectedServerDateString
150+
)
151+
expect(serverDate.getTime()).toEqual(javascriptDate.getTime())
152+
})
153+
})

services/config/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { useConfig } from './useConfig'
2+
export { useTimeZoneConversion } from './useTimeZoneConversion'
23
export { ConfigProvider } from './ConfigProvider'
34

45
export type { Config } from './types'

services/config/src/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@ type Version = {
66
tag?: string
77
}
88

9+
export type DateInput = string | Date | number | null
10+
911
interface SystemInfo {
1012
version: string
1113
contextPath: string
14+
serverTimeZoneId: string
1215
}
1316

1417
export interface Config {
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { useCallback, useMemo } from 'react'
2+
import { DateInput } from './types'
3+
import { useConfig } from './useConfig'
4+
5+
// extend date with extra methods
6+
class DHIS2Date extends Date {
7+
serverOffset: number
8+
serverTimezone: string
9+
clientTimezone: string
10+
11+
constructor({
12+
date,
13+
serverOffset,
14+
serverTimezone,
15+
clientTimezone,
16+
}: {
17+
date: DateInput
18+
serverOffset: number
19+
serverTimezone: string
20+
clientTimezone: string
21+
}) {
22+
if (date) {
23+
super(date)
24+
} else {
25+
super(Date.now())
26+
}
27+
this.serverOffset = serverOffset
28+
this.serverTimezone = serverTimezone
29+
this.clientTimezone = clientTimezone
30+
}
31+
32+
private _getISOString(date: Date): string {
33+
const year = date.getFullYear().toString().padStart(4, '0')
34+
const month = (date.getMonth() + 1).toString().padStart(2, '0')
35+
const days = date.getDate().toString().padStart(2, '0')
36+
const hours = date.getHours().toString().padStart(2, '0')
37+
const minutes = date.getMinutes().toString().padStart(2, '0')
38+
const seconds = date.getSeconds().toString().padStart(2, '0')
39+
const milliseconds = date.getMilliseconds().toString().padStart(3, '0')
40+
return `${year}-${month}-${days}T${hours}:${minutes}:${seconds}.${milliseconds}`
41+
}
42+
43+
public getServerZonedISOString(): string {
44+
const serverDate = new Date(this.getTime() - this.serverOffset)
45+
return this._getISOString(serverDate)
46+
}
47+
48+
public getClientZonedISOString(): string {
49+
return this._getISOString(this)
50+
}
51+
}
52+
53+
const useServerTimeOffset = (serverTimezone: string): number => {
54+
return useMemo(() => {
55+
try {
56+
const nowClientTime = new Date()
57+
nowClientTime.setMilliseconds(0)
58+
59+
// 'sv' is used for localeString because it is the closest to ISO format
60+
// in principle, any locale should be parsable back to a date, but we encountered an error
61+
// when using en-US in certain environments, which we could not replicate when using 'sv'
62+
// Converting to localeString and then back to date is unfortunately the only current way
63+
// to construct a date that accounts for timezone.
64+
const serverLocaleString = nowClientTime.toLocaleString('sv', {
65+
timeZone: serverTimezone,
66+
})
67+
const nowServerTimeZone = new Date(serverLocaleString)
68+
nowServerTimeZone.setMilliseconds(0)
69+
70+
return nowClientTime.getTime() - nowServerTimeZone.getTime()
71+
} catch (err) {
72+
console.error(
73+
'Server time offset could not be determined; assuming no client/server difference',
74+
err
75+
)
76+
// if date is not constructable with timezone, assume 0 difference between client/server
77+
return 0
78+
}
79+
}, [serverTimezone])
80+
}
81+
82+
export const useTimeZoneConversion = (): {
83+
fromServerDate: (date?: DateInput) => DHIS2Date
84+
fromClientDate: (date?: DateInput) => DHIS2Date
85+
} => {
86+
const { systemInfo } = useConfig()
87+
let serverTimezone: string
88+
const clientTimezone: string =
89+
Intl.DateTimeFormat().resolvedOptions().timeZone
90+
91+
if (systemInfo?.serverTimeZoneId) {
92+
serverTimezone = systemInfo.serverTimeZoneId
93+
} else {
94+
// Fallback to client timezone
95+
serverTimezone = clientTimezone
96+
console.warn(
97+
'No server timezone ID found, falling back to client timezone. This could cause date conversion issues.'
98+
)
99+
}
100+
101+
const serverOffset = useServerTimeOffset(serverTimezone)
102+
103+
const fromServerDate = useCallback(
104+
(date) => {
105+
const serverDate = new Date(date)
106+
const clientDate = new DHIS2Date({
107+
date: serverDate.getTime() + serverOffset,
108+
serverOffset,
109+
serverTimezone,
110+
clientTimezone,
111+
})
112+
113+
return clientDate
114+
},
115+
[serverOffset, serverTimezone, clientTimezone]
116+
)
117+
118+
const fromClientDate = useCallback(
119+
(date) => {
120+
const clientDate = new DHIS2Date({
121+
date,
122+
serverOffset,
123+
serverTimezone,
124+
clientTimezone,
125+
})
126+
127+
return clientDate
128+
},
129+
[serverOffset, serverTimezone, clientTimezone]
130+
)
131+
132+
return useMemo(
133+
() => ({ fromServerDate, fromClientDate }),
134+
[fromServerDate, fromClientDate]
135+
)
136+
}

0 commit comments

Comments
 (0)