-
Notifications
You must be signed in to change notification settings - Fork 13
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add hook to adjust for server time [LIBS-396] (#1308)
- Loading branch information
Showing
6 changed files
with
295 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
153 changes: 153 additions & 0 deletions
153
services/config/src/__tests__/useTimeZoneConversion.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 }) => ( | ||
<ConfigProvider config={config}>{children}</ConfigProvider> | ||
) | ||
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 }) => ( | ||
<ConfigProvider config={config}>{children}</ConfigProvider> | ||
) | ||
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 }) => ( | ||
<ConfigProvider config={config}>{children}</ConfigProvider> | ||
) | ||
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 }) => ( | ||
<ConfigProvider config={config}>{children}</ConfigProvider> | ||
) | ||
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 }) => ( | ||
<ConfigProvider config={config}>{children}</ConfigProvider> | ||
) | ||
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 }) => ( | ||
<ConfigProvider config={config}>{children}</ConfigProvider> | ||
) | ||
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 }) => ( | ||
<ConfigProvider config={config}>{children}</ConfigProvider> | ||
) | ||
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()) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,5 @@ | ||
export { useConfig } from './useConfig' | ||
export { useTimeZoneConversion } from './useTimeZoneConversion' | ||
export { ConfigProvider } from './ConfigProvider' | ||
|
||
export type { Config } from './types' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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] | ||
) | ||
} |