Skip to content

Commit

Permalink
feat: add hook to adjust for server time [LIBS-396] (#1308)
Browse files Browse the repository at this point in the history
  • Loading branch information
tomzemp authored Jan 19, 2023
1 parent 2aea6e2 commit d511303
Show file tree
Hide file tree
Showing 6 changed files with 295 additions and 2 deletions.
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]
)
}

0 comments on commit d511303

Please sign in to comment.