Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add hook to adjust for server time [LIBS-396] #1308

Merged
merged 9 commits into from
Jan 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
2 changes: 1 addition & 1 deletion services/config/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
153 changes: 153 additions & 0 deletions services/config/src/__tests__/useTimeZoneConversion.test.tsx
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())
})
})
1 change: 1 addition & 0 deletions services/config/src/index.ts
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'
3 changes: 3 additions & 0 deletions services/config/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
136 changes: 136 additions & 0 deletions services/config/src/useTimeZoneConversion.ts
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]
)
}