Skip to content

Commit 4090cb6

Browse files
authored
fixing minute offset issue in certain time zones (#1238)
Timezones with minute offsets not divisible by 60 were causing issues for converting from the driver implementation of Date and Datetime to JS Date. A better implementation has been created and some documentation has been updated to reflect limitations that already existed.
1 parent 766355e commit 4090cb6

File tree

9 files changed

+133
-168
lines changed

9 files changed

+133
-168
lines changed

packages/bolt-connection/test/test-utils.js

-7
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,6 @@ function isServer () {
3131
return !isClient()
3232
}
3333

34-
function fakeStandardDateWithOffset (offsetMinutes) {
35-
const date = new Date()
36-
date.getTimezoneOffset = () => offsetMinutes
37-
return date
38-
}
39-
4034
const matchers = {
4135
toBeElementOf: function (actual, expected) {
4236
if (expected === undefined) {
@@ -161,7 +155,6 @@ function arbitraryTimeZoneId () {
161155
export default {
162156
isClient,
163157
isServer,
164-
fakeStandardDateWithOffset,
165158
matchers,
166159
MessageRecordingConnection,
167160
spyProtocolWrite,

packages/core/src/internal/temporal-util.ts

+43-13
Original file line numberDiff line numberDiff line change
@@ -339,24 +339,54 @@ export function totalNanoseconds (
339339
/**
340340
* Get the time zone offset in seconds from the given standard JavaScript date.
341341
*
342-
* <b>Implementation note:</b>
343-
* Time zone offset returned by the standard JavaScript date is the difference, in minutes, from local time to UTC.
344-
* So positive value means offset is behind UTC and negative value means it is ahead.
345-
* For Neo4j temporal types, like `Time` or `DateTime` offset is in seconds and represents difference from UTC to local time.
346-
* This is different from standard JavaScript dates and that's why implementation negates the returned value.
347-
*
348342
* @param {global.Date} standardDate the standard JavaScript date.
349343
* @return {number} the time zone offset in seconds.
350344
*/
351345
export function timeZoneOffsetInSeconds (standardDate: Date): number {
352-
const secondsPortion = standardDate.getSeconds() >= standardDate.getUTCSeconds()
353-
? standardDate.getSeconds() - standardDate.getUTCSeconds()
354-
: standardDate.getSeconds() - standardDate.getUTCSeconds() + 60
355-
const offsetInMinutes = standardDate.getTimezoneOffset()
356-
if (offsetInMinutes === 0) {
357-
return 0 + secondsPortion
346+
const secondsPortion = standardDate.getSeconds() - standardDate.getUTCSeconds()
347+
const minutesPortion = standardDate.getMinutes() - standardDate.getUTCMinutes()
348+
const hoursPortion = standardDate.getHours() - standardDate.getUTCHours()
349+
const daysPortion = _getDayOffset(standardDate)
350+
return hoursPortion * SECONDS_PER_HOUR + minutesPortion * SECONDS_PER_MINUTE + secondsPortion + daysPortion * SECONDS_PER_DAY
351+
}
352+
353+
/**
354+
* Get the difference in days from the given JavaScript date in local time and UTC.
355+
*
356+
* @private
357+
* @param {global.Date} standardDate the date to evaluate
358+
* @returns {number} the difference in days between date local time and UTC
359+
*/
360+
function _getDayOffset (standardDate: Date): number {
361+
if (standardDate.getMonth() === standardDate.getUTCMonth()) {
362+
return standardDate.getDate() - standardDate.getUTCDate()
363+
} else if ((standardDate.getFullYear() > standardDate.getUTCFullYear()) || (standardDate.getMonth() > standardDate.getUTCMonth() && standardDate.getFullYear() === standardDate.getUTCFullYear())) {
364+
return standardDate.getDate() + _daysUntilNextMonth(standardDate.getUTCMonth(), standardDate.getUTCFullYear()) - standardDate.getUTCDate()
365+
} else {
366+
return standardDate.getDate() - (standardDate.getUTCDate() + _daysUntilNextMonth(standardDate.getMonth(), standardDate.getFullYear()))
367+
}
368+
}
369+
370+
/**
371+
* Get the number of days in a month, including a check for leap years.
372+
*
373+
* @private
374+
* @param {number} month the month of the date to evalutate
375+
* @param {number} year the month of the date to evalutate
376+
* @returns {number} the total number of days in the month evaluated
377+
*/
378+
function _daysUntilNextMonth (month: number, year: number): number {
379+
if (month === 1) {
380+
if (year % 400 === 0 || (year % 4 === 0 && year % 100 !== 0)) {
381+
return 29
382+
} else {
383+
return 28
384+
}
385+
} else if ([0, 2, 4, 6, 7, 9, 11].includes(month)) {
386+
return 31
387+
} else {
388+
return 30
358389
}
359-
return -1 * offsetInMinutes * SECONDS_PER_MINUTE + secondsPortion
360390
}
361391

362392
/**

packages/core/src/temporal-types.ts

+6
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,10 @@ export class Date<T extends NumberOrInteger = Integer> {
353353
/**
354354
* Create a {@link Date} object from the given standard JavaScript `Date`.
355355
* Hour, minute, second, millisecond and time zone offset components of the given date are ignored.
356+
*
357+
* NOTE: the function {@link toStandardDate} and {@link fromStandardDate} are not inverses of one another. {@link fromStandardDate} takes the Day, Month and Year in local time from the supplies JavaScript Date object, while {@link toStandardDate} creates a new JavaScript Date object at midnight UTC. This incongruity will be rectified in 6.0
358+
* If your timezone has a negative offset from UTC, creating a JavaScript Date at midnight UTC and converting it with {@link fromStandardDate} will result in a Date for the day before.
359+
*
356360
* @param {global.Date} standardDate - The standard JavaScript date to convert.
357361
* @return {Date} New Date.
358362
*/
@@ -372,6 +376,8 @@ export class Date<T extends NumberOrInteger = Integer> {
372376
* The time component of the returned `Date` is set to midnight
373377
* and the time zone is set to UTC.
374378
*
379+
* NOTE: the function {@link toStandardDate} and {@link fromStandardDate} are not inverses of one another. {@link fromStandardDate} takes the Day, Month and Year in local time from the supplies JavaScript Date object, while {@link toStandardDate} creates a new JavaScript Date object at midnight UTC. This incongruity will be rectified in 6.0
380+
*
375381
* @returns {StandardDate} Standard JavaScript `Date` at `00:00:00.000` UTC.
376382
*/
377383
toStandardDate (): StandardDate {

packages/core/test/temporal-types.test.ts

+35-49
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
* limitations under the License.
1616
*/
1717

18-
import { StandardDate } from '../src/graph-types'
1918
import { LocalDateTime, Date, DateTime, Duration, isDuration, LocalTime, isLocalTime, Time, isTime, isDate, isLocalDateTime, isDateTime } from '../src/temporal-types'
2019
import { temporalUtil } from '../src/internal'
2120
import fc from 'fast-check'
@@ -31,9 +30,9 @@ describe('Date', () => {
3130

3231
const standardDate = localDatetime.toStandardDate()
3332

34-
expect(standardDate.getFullYear()).toEqual(localDatetime.year)
35-
expect(standardDate.getMonth()).toEqual(localDatetime.month - 1)
36-
expect(standardDate.getDate()).toEqual(localDatetime.day)
33+
expect(standardDate.getUTCFullYear()).toEqual(localDatetime.year)
34+
expect(standardDate.getUTCMonth()).toEqual(localDatetime.month - 1)
35+
expect(standardDate.getUTCDate()).toEqual(localDatetime.day)
3736
})
3837

3938
it('should be the reverse operation of fromStandardDate but losing time information', () => {
@@ -47,14 +46,11 @@ describe('Date', () => {
4746
const date = Date.fromStandardDate(standardDate)
4847
const receivedDate = date.toStandardDate()
4948

50-
const adjustedDateTime = temporalUtil.newDate(standardDate)
51-
adjustedDateTime.setHours(0, offset(receivedDate))
52-
53-
expect(receivedDate.getFullYear()).toEqual(adjustedDateTime.getFullYear())
54-
expect(receivedDate.getMonth()).toEqual(adjustedDateTime.getMonth())
55-
expect(receivedDate.getDate()).toEqual(adjustedDateTime.getDate())
56-
expect(receivedDate.getHours()).toEqual(adjustedDateTime.getHours())
57-
expect(receivedDate.getMinutes()).toEqual(adjustedDateTime.getMinutes())
49+
expect(receivedDate.getUTCFullYear()).toEqual(standardDate.getFullYear()) // Date converts from local time but to UTC
50+
expect(receivedDate.getUTCMonth()).toEqual(standardDate.getMonth())
51+
expect(receivedDate.getUTCDate()).toEqual(standardDate.getDate())
52+
expect(receivedDate.getUTCHours()).toEqual(0)
53+
expect(receivedDate.getUTCMinutes()).toEqual(0)
5854
})
5955
)
6056
})
@@ -113,35 +109,33 @@ describe('DateTime', () => {
113109

114110
const standardDate = datetime.toStandardDate()
115111

116-
expect(standardDate.getFullYear()).toEqual(datetime.year)
117-
expect(standardDate.getMonth()).toEqual(datetime.month - 1)
118-
expect(standardDate.getDate()).toEqual(datetime.day)
119-
const offsetInMinutes = offset(standardDate)
120-
const offsetAdjust = offsetInMinutes - (datetime.timeZoneOffsetSeconds ?? 0) / 60
121-
const hourDiff = Math.abs(offsetAdjust / 60)
112+
expect(standardDate.getUTCFullYear()).toEqual(datetime.year)
113+
expect(standardDate.getUTCMonth()).toEqual(datetime.month - 1)
114+
expect(standardDate.getUTCDate()).toEqual(datetime.day) // The datetime in this test will never cross the date line in conversion, it is therefore safe to use UTC here to avoid machine timezone from altering the result of the test.
115+
const offsetAdjust = (datetime.timeZoneOffsetSeconds ?? 0) / 60
116+
const hourDiff = Math.abs((offsetAdjust - offsetAdjust % 60) / 60)
122117
const minuteDiff = Math.abs(offsetAdjust % 60)
123-
expect(standardDate.getHours()).toBe(datetime.hour - hourDiff)
124-
expect(standardDate.getMinutes()).toBe(datetime.minute - minuteDiff)
125-
expect(standardDate.getSeconds()).toBe(datetime.second)
126-
expect(standardDate.getMilliseconds()).toBe(Math.round(datetime.nanosecond / 1000000))
118+
expect(standardDate.getUTCHours()).toBe(datetime.hour - hourDiff)
119+
expect(standardDate.getUTCMinutes()).toBe(datetime.minute - minuteDiff)
120+
expect(standardDate.getUTCSeconds()).toBe(datetime.second)
121+
expect(standardDate.getUTCMilliseconds()).toBe(Math.round(datetime.nanosecond / 1000000))
127122
})
128123

129124
it('should convert to a standard date (offset)', () => {
130125
const datetime = new DateTime(2020, 12, 15, 12, 2, 3, 4000000, 120 * 60)
131126

132127
const standardDate = datetime.toStandardDate()
133128

134-
expect(standardDate.getFullYear()).toEqual(datetime.year)
135-
expect(standardDate.getMonth()).toEqual(datetime.month - 1)
136-
expect(standardDate.getDate()).toEqual(datetime.day)
137-
const offsetInMinutes = offset(standardDate)
138-
const offsetAdjust = offsetInMinutes - (datetime.timeZoneOffsetSeconds ?? 0) / 60
139-
const hourDiff = Math.abs(offsetAdjust / 60)
129+
expect(standardDate.getUTCFullYear()).toEqual(datetime.year)
130+
expect(standardDate.getUTCMonth()).toEqual(datetime.month - 1)
131+
expect(standardDate.getUTCDate()).toEqual(datetime.day)
132+
const offsetAdjust = (datetime.timeZoneOffsetSeconds ?? 0) / 60
133+
const hourDiff = Math.abs((offsetAdjust - offsetAdjust % 60) / 60)
140134
const minuteDiff = Math.abs(offsetAdjust % 60)
141-
expect(standardDate.getHours()).toBe(datetime.hour - hourDiff)
142-
expect(standardDate.getMinutes()).toBe(datetime.minute - minuteDiff)
143-
expect(standardDate.getSeconds()).toBe(datetime.second)
144-
expect(standardDate.getMilliseconds()).toBe(Math.round(datetime.nanosecond / 1000000))
135+
expect(standardDate.getUTCHours()).toBe(datetime.hour - hourDiff)
136+
expect(standardDate.getUTCMinutes()).toBe(datetime.minute - minuteDiff)
137+
expect(standardDate.getUTCSeconds()).toBe(datetime.second)
138+
expect(standardDate.getUTCMilliseconds()).toBe(Math.round(datetime.nanosecond / 1000000))
145139
})
146140

147141
it('should not convert to a standard date (zoneid)', () => {
@@ -153,12 +147,16 @@ describe('DateTime', () => {
153147

154148
it('should be the reverse operation of fromStandardDate', () => {
155149
fc.assert(
156-
fc.property(fc.date(), (date) => {
157-
const datetime = DateTime.fromStandardDate(date)
158-
const receivedDate = datetime.toStandardDate()
150+
fc.property(
151+
fc.date({
152+
max: temporalUtil.newDate(MAX_UTC_IN_MS - ONE_DAY_IN_MS),
153+
min: temporalUtil.newDate(MIN_UTC_IN_MS + ONE_DAY_IN_MS)
154+
}), (date) => {
155+
const datetime = DateTime.fromStandardDate(date)
156+
const receivedDate = datetime.toStandardDate()
159157

160-
expect(receivedDate).toEqual(date)
161-
})
158+
expect(receivedDate).toEqual(date)
159+
})
162160
)
163161
})
164162
})
@@ -284,15 +282,3 @@ describe('isDateTime', () => {
284282
}
285283
})
286284
})
287-
288-
/**
289-
* The offset in StandardDate is the number of minutes
290-
* to sum to the date and time to get the UTC time.
291-
*
292-
* This function change the sign of the offset,
293-
* this way using the most common meaning.
294-
* The time to add to UTC to get the local time.
295-
*/
296-
function offset (date: StandardDate): number {
297-
return date.getTimezoneOffset() * -1
298-
}

packages/neo4j-driver-deno/lib/core/internal/temporal-util.ts

+43-13
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/neo4j-driver-deno/lib/core/temporal-types.ts

+6
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/neo4j-driver/test/internal/temporal-util.test.js

-22
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
*/
1717

1818
import { int, internal } from 'neo4j-driver-core'
19-
import testUtils from './test-utils'
2019

2120
const { temporalUtil: util } = internal
2221

@@ -261,27 +260,6 @@ describe('#unit temporal-util', () => {
261260
).toEqual(BigInt(999000111))
262261
})
263262

264-
it('should get timezone offset in seconds from standard date', () => {
265-
expect(
266-
util.timeZoneOffsetInSeconds(testUtils.fakeStandardDateWithOffset(0))
267-
).toBe(0)
268-
expect(
269-
util.timeZoneOffsetInSeconds(testUtils.fakeStandardDateWithOffset(2))
270-
).toBe(-120)
271-
expect(
272-
util.timeZoneOffsetInSeconds(testUtils.fakeStandardDateWithOffset(10))
273-
).toBe(-600)
274-
expect(
275-
util.timeZoneOffsetInSeconds(testUtils.fakeStandardDateWithOffset(101))
276-
).toBe(-6060)
277-
expect(
278-
util.timeZoneOffsetInSeconds(testUtils.fakeStandardDateWithOffset(-180))
279-
).toBe(10800)
280-
expect(
281-
util.timeZoneOffsetInSeconds(testUtils.fakeStandardDateWithOffset(-600))
282-
).toBe(36000)
283-
})
284-
285263
it('should verify year', () => {
286264
expect(util.assertValidYear(-1)).toEqual(-1)
287265
expect(util.assertValidYear(-2010)).toEqual(-2010)

packages/neo4j-driver/test/internal/test-utils.js

-7
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,6 @@ function isServer () {
2424
return !isClient()
2525
}
2626

27-
function fakeStandardDateWithOffset (offsetMinutes) {
28-
const date = new Date()
29-
date.getTimezoneOffset = () => offsetMinutes
30-
return date
31-
}
32-
3327
const matchers = {
3428
toBeElementOf: function (util, customEqualityTesters) {
3529
return {
@@ -138,7 +132,6 @@ function spyProtocolWrite (protocol, callRealMethod = false) {
138132
export default {
139133
isClient,
140134
isServer,
141-
fakeStandardDateWithOffset,
142135
matchers,
143136
MessageRecordingConnection,
144137
spyProtocolWrite

0 commit comments

Comments
 (0)