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

fix: account for daylight savings time [LIBS-490] #1345

Merged
merged 2 commits into from
Apr 11, 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
43 changes: 43 additions & 0 deletions services/config/src/__tests__/useTimeZoneConversion.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,24 @@ describe('useTimeZoneConversion', () => {
expect(serverDate.getClientZonedISOString()).toBe(expectedDateString)
})

it('returns fromServerDate that corrects for server time zone (adjusting for summer time)', () => {
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-07-01')
const expectedDateString = '2010-06-30T22: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 }
Expand Down Expand Up @@ -107,6 +125,31 @@ describe('useTimeZoneConversion', () => {
expect(serverDate.getClientZonedISOString()).toBe(expectedDateString)
})

it('returns fromServerDate that assumes no time zone difference if client and server time zones are the same', () => {
const systemInfo = {
...defaultSystemInfo,
serverTimeZoneId: 'Africa/Kampala',
}
jest.spyOn(Intl, 'DateTimeFormat').mockReturnValue({
resolvedOptions: () => {
return {
timeZone: 'Africa/Kampala',
}
},
} as Intl.DateTimeFormat)
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,
Expand Down
82 changes: 58 additions & 24 deletions services/config/src/useTimeZoneConversion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,40 +50,64 @@ class DHIS2Date extends Date {
}
}

const useServerTimeOffset = (serverTimezone: string): number => {
const calculateOffset = (inputDate: any, serverTimezone: string) => {
// we need to assume that the inputDate is in the client time zone due to limitations of javascript logic
// note that this assumption is will be imperfect around daylight savings time changes
const thenClientTime = new Date(inputDate)
thenClientTime.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 = thenClientTime.toLocaleString('sv', {
timeZone: serverTimezone,
})

const thenServerTimeZone = new Date(serverLocaleString)

return thenClientTime.getTime() - thenServerTimeZone.getTime()
}

/**
* Determines if the server/client time zone offset can and should be calculated
* @param {string} serverTimezone string representation of server time zone (Area/Location)
* * @param {string} clientTimezone string representation of client time zone (Area/Location)
* @return {boolean} shouldCalculateOffset
*/

const useShouldCalculateOffset = (
serverTimezone: string,
clientTimezone: string
): boolean => {
return useMemo(() => {
// if client and server time zones are the same, offset is 0 and does not need to be subsequently calculated
if (serverTimezone === clientTimezone) {
return false
}
// attempt to calculate current time zone offset, if calcublable: return true; if not calculable, alert and return false
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()
calculateOffset(nowClientTime, serverTimezone)
return true
} 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
return false
}
}, [serverTimezone])
}, [serverTimezone, clientTimezone])
}

export const useTimeZoneConversion = (): {
fromServerDate: (date?: DateInput) => DHIS2Date
fromClientDate: (date?: DateInput) => DHIS2Date
} => {
const { systemInfo } = useConfig()

let serverTimezone: string
const clientTimezone: string =
Intl.DateTimeFormat().resolvedOptions().timeZone
Expand All @@ -98,35 +122,45 @@ export const useTimeZoneConversion = (): {
)
}

const serverOffset = useServerTimeOffset(serverTimezone)
const shouldCalculateOffset = useShouldCalculateOffset(
serverTimezone,
clientTimezone
)

const fromServerDate = useCallback(
(date) => {
const serverDate = new Date(date)
const jsServerDate = date ? new Date(date) : new Date(Date.now())
const offset = shouldCalculateOffset
? calculateOffset(jsServerDate, serverTimezone)
: 0
const clientDate = new DHIS2Date({
date: serverDate.getTime() + serverOffset,
serverOffset,
date: jsServerDate.getTime() + offset,
serverOffset: offset,
serverTimezone,
clientTimezone,
})

return clientDate
},
[serverOffset, serverTimezone, clientTimezone]
[shouldCalculateOffset, serverTimezone, clientTimezone]
)

const fromClientDate = useCallback(
(date) => {
const jsClientDate = date ? new Date(date) : new Date(Date.now())
const offset = shouldCalculateOffset
? calculateOffset(jsClientDate, serverTimezone)
: 0
const clientDate = new DHIS2Date({
date,
serverOffset,
serverOffset: offset,
serverTimezone,
clientTimezone,
})

return clientDate
},
[serverOffset, serverTimezone, clientTimezone]
[shouldCalculateOffset, serverTimezone, clientTimezone]
)

return useMemo(
Expand Down