marnusw / date-fns-tz

Complementary library for date-fns v2 adding IANA time zone support
MIT License
1.05k stars 114 forks source link

DST change issue #121

Open matthew-dev opened 3 years ago

matthew-dev commented 3 years ago

⚠️ I guess there is an issue regarding having DST change in both browser's time zone and selected time zone within the same day but happening in a different hour.

Lets' consider the API provides us with a date (in ISO string format), which we need to show in the chosen time zone.

Browser's time zone - Europe/Bratislava Selected time zone - Europe/London

I prepared some dates around DST change, just one of them seems to be not aligned.

/**
 * UTC to Europe/London evaluated in Europe/Bratislava TZ
 * note that DST change in Europe/Bratislava happens at 2AM -> 3AM
 */

format(
  utcToZonedTime('2022-03-27T00:00:00.000Z', 'Europe/London'), 
  'yyyy-MM-dd HH:mm:ss zzz', 
  { timeZone: 'Europe/London'}
)
// ✅ 2022-03-27 00:00:00 GMT

format(
  utcToZonedTime('2022-03-27T01:00:00.000Z', 'Europe/London'), 
  'yyyy-MM-dd HH:mm:ss zzz', 
  { timeZone: 'Europe/London'}
)
// 2022-03-27 03:00:00 GMT+1
// ❌ WRONG! should be 2022-03-27 02:00:00 GMT+1 as DST change in London TZ happens at 1AM -> 2AM
// see https://www.timeanddate.com/worldclock/converter.html?iso=20220327T010000&p1=1440&p2=735&p3=136

format(
  utcToZonedTime('2022-03-27T02:00:00.000Z', 'Europe/London'), 
  'yyyy-MM-dd HH:mm:ss zzz', 
  { timeZone: 'Europe/London'}
)
// ✅ 2022-03-27 03:00:00 GMT+1

format(
  utcToZonedTime('2022-03-27T03:00:00.000Z', 'Europe/London'), 
  'yyyy-MM-dd HH:mm:ss zzz', 
  { timeZone: 'Europe/London'}
)
// ✅ 2022-03-27 04:00:00 GMT+1

  I tried to list offsets for those dates, but all of them seem to be fine.

getTimezoneOffset('Europe/London', utcToZonedTime('2022-03-27T00:00:00.000Z', 'Europe/London'))
// ✅ 0

getTimezoneOffset('Europe/London', utcToZonedTime('2022-03-27T01:00:00.000Z', 'Europe/London'))
// ✅ 3600000

getTimezoneOffset('Europe/London', utcToZonedTime('2022-03-27T02:00:00.000Z', 'Europe/London'))
// ✅ 3600000

getTimezoneOffset('Europe/London', utcToZonedTime('2022-03-27T03:00:00.000Z', 'Europe/London'))
// ✅ 3600000

  When we switch those 2 timezones (browser to Europe/London and selected one to Europe/Bratislava), there is also 1 misaligned value, but this time happening at a different hour.

/**
 * UTC to Europe/Bratislava evaluated in Europe/London TZ
 * note that DST change in Europe/London happens at 1 AM -> 2 AM
 */

format(
  utcToZonedTime(
  '2022-03-27T00:00:00.000Z', 'Europe/Bratislava'), 
  'yyyy-MM-dd HH:mm:ss zzz', 
  { timeZone: 'Europe/Bratislava'}
)
// 2022-03-27 02:00:00 SELČ 
// ❌ WRONG! should be 2022-03-27 01:00:00 SEČ as DST change still didn't happen in Bratislava TZ
// see https://www.timeanddate.com/worldclock/converter.html?iso=20220327T000000&p1=1440&p2=136&p3=735

format(
  utcToZonedTime('2022-03-27T01:00:00.000Z', 'Europe/Bratislava'), 
  'yyyy-MM-dd HH:mm:ss zzz', 
  { timeZone: 'Europe/Bratislava'}
)
// ✅ 2022-03-27 03:00:00 SELČ 

format(
  utcToZonedTime('2022-03-27T02:00:00.000Z', 'Europe/Bratislava'), 
  'yyyy-MM-dd HH:mm:ss zzz', 
  { timeZone: 'Europe/Bratislava'}
)
// ✅ 2022-03-27 04:00:00 SELČ

format(
  utcToZonedTime('2022-03-27T03:00:00.000Z', 'Europe/Bratislava'), 
  'yyyy-MM-dd HH:mm:ss zzz', 
  { timeZone: 'Europe/Bratislava'}
)
// ✅ 2022-03-27 05:00:00 SELČ

  Same as for the previous check, all the listed offsets seem to be fine.

getTimezoneOffset('Europe/Bratislava', utcToZonedTime('2022-03-27T00:00:00.000Z', 'Europe/Bratislava'))
// ✅ 3600000 

getTimezoneOffset('Europe/Bratislava', utcToZonedTime('2022-03-27T01:00:00.000Z', 'Europe/Bratislava'))
// ✅ 7200000

getTimezoneOffset('Europe/Bratislava', utcToZonedTime('2022-03-27T02:00:00.000Z', 'Europe/Bratislava'))
// ✅ 7200000 

getTimezoneOffset('Europe/Bratislava', utcToZonedTime('2022-03-27T03:00:00.000Z', 'Europe/Bratislava'))
// ✅ 7200000

The issue was spotted on the latest version 1.1.4. Thanks.

matthew-dev commented 3 years ago

There is another inconsistency when trying to get offset for the same hour that DST change occurs when browser's time zone and selected one are equal to Europe/Bratislava:

getTimezoneOffset(
  "Europe/Bratislava", 
  utcToZonedTime("2022-03-27T01:00:00.000Z", "Europe/Bratislava")
) / (60 * 1000 * -1)
// -60
//  ❌ WRONG! that date refers to 3 AM in local time, which is having offset UTC+2 - summer time (SELČ)

new Date('2022-03-27T01:00:00.000Z').getTimezoneOffset(),
// ✅ -120

format(new Date('2022-03-27T01:00:00.000Z'), 'yyyy-MM-dd HH:mm:ss zzz', { timeZone: "Europe/Bratislava" }),
// 2022-03-27 03:00:00 SELČ 

  I tried the same behavior for Europe/London time zone (for both browser and selection), but couldn't replicate the same issue:

getTimezoneOffset(
  "Europe/London", 
  utcToZonedTime("2022-03-27T01:00:00.000Z", "Europe/London")
) / (60 * 1000 * -1),
// ✅ -60

new Date('2022-03-27T01:00:00.000Z').getTimezoneOffset(),
// ✅ -60

format(new Date('2022-03-27T01:00:00.000Z'), 'yyyy-MM-dd HH:mm:ss zzz', { timeZone: "Europe/London" })
// 2022-03-27 02:00:00 GMT+1
matthew-dev commented 3 years ago

I also tried the transition from summertime to wintertime and unfortunately, there is another issue related to this. This time, format seems to provide correct time values(but not the correct timezone), and getTimeZoneOffset also provides me with some misaligned values 😥   Browser's time zone - Europe/Bratislava Selected time zone - Europe/London

/**
 * UTC to Europe/London evaluated in Europe/Bratislava TZ
 * note that DST change in Europe/Bratislava happens at 3AM -> 2AM
 */

getTimezoneOffset(
  "Europe/London",
  utcToZonedTime("2021-10-31T00:00:00.000Z", "Europe/London")
) / (60 * 1000 * -1)
// ✅ -60

getTimezoneOffset(
  "Europe/London",
  utcToZonedTime("2021-10-31T01:00:00.000Z", "Europe/London")
) / (60 * 1000 * -1)
// ❌ -60
// WRONG! should be 0 as it refers to 1 AM being aligned to UTC TZ - DST change was just applied

getTimezoneOffset(
  "Europe/London",
  utcToZonedTime("2021-10-31T02:00:00.000Z", "Europe/London")
) / (60 * 1000 * -1)
// ❌ -60
// WRONG! should be 0 as it refers to 2 AM being aligned to UTC TZ

getTimezoneOffs7et(
  "Europe/London",
  utcToZonedTime("2021-10-31T03:00:00.000Z", "Europe/London")
) / (60 * 1000 * -1)
// ✅ 0

  Checks for the format helper:

format(
  utcToZonedTime("2021-10-31T00:00:00.000Z", "Europe/London"),
  "yyyy-MM-dd HH:mm:ss zzz",
  { timeZone: "Europe/London" }
)
// ✅ 2021-10-31 01:00:00 GMT+1

format(
  utcToZonedTime("2021-10-31T01:00:00.000Z", "Europe/London"),
  "yyyy-MM-dd HH:mm:ss zzz",
  { timeZone: "Europe/London" }
)
// ❌ 2021-10-31 01:00:00 GMT+1
// time value is fine, but timezone should be GMT 

format(
  utcToZonedTime("2021-10-31T02:00:00.000Z", "Europe/London"),
  "yyyy-MM-dd HH:mm:ss zzz",
  { timeZone: "Europe/London" }
)
// ❌ 2021-10-31 02:00:00 GMT+1
// time value is fine, but timezone should be GMT 

format(
  utcToZonedTime("2021-10-31T03:00:00.000Z", "Europe/London"),
  "yyyy-MM-dd HH:mm:ss zzz",
  { timeZone: "Europe/London" }
)
// ✅ 2021-10-31 03:00:00 GMT 

  Vice versa (browser's time zone Europe/London, selected one Europe/Bratislava):

/**
 * UTC to Europe/Bratislava evaluated in Europe/London TZ
 * note that DST change in Europe/London happens at 2 AM -> 1 AM
 */

getTimezoneOffset(
  "Europe/Bratislava",
  utcToZonedTime("2021-10-31T00:00:00.000Z", "Europe/Bratislava")
) / (60 * 1000 * -1),
// ❌ -60
// WRONG! should be -120 as it refers to 2 AM (1 hour before DST change will be applied)

getTimezoneOffset(
  "Europe/Bratislava",
  utcToZonedTime("2021-10-31T01:00:00.000Z", "Europe/Bratislava")
) / (60 * 1000 * -1),
// ✅ -60

getTimezoneOffset(
  "Europe/Bratislava",
  utcToZonedTime("2021-10-31T02:00:00.000Z", "Europe/Bratislava")
) / (60 * 1000 * -1),
// ✅ -60

getTimezoneOffset(
  "Europe/Bratislava",
  utcToZonedTime("2021-10-31T03:00:00.000Z", "Europe/Bratislava")
) / (60 * 1000 * -1)
// ✅ -60

  Some more checks for the format helper:

format( 
  utcToZonedTime("2021-10-31T00:00:00.000Z", "Europe/Bratislava"),
  "yyyy-MM-dd HH:mm:ss zzz",
  { timeZone: "Europe/Bratislava" }
)
// ❌ 2021-10-31 02:00:00 SEČ
// time value is fine, but timezone should be SELČ (UTC+2) 

format(
  utcToZonedTime("2021-10-31T01:00:00.000Z", "Europe/Bratislava"),
  "yyyy-MM-dd HH:mm:ss zzz",
  { timeZone: "Europe/Bratislava" }
)
// ✅ 2021-10-31 02:00:00 SEČ

format(
  utcToZonedTime("2021-10-31T02:00:00.000Z", "Europe/Bratislava"),
  "yyyy-MM-dd HH:mm:ss zzz",
  { timeZone: "Europe/Bratislava" }
)
// ✅ 2021-10-31 03:00:00 SEČ

format(
  utcToZonedTime("2021-10-31T03:00:00.000Z", "Europe/Bratislava"),
  "yyyy-MM-dd HH:mm:ss zzz",
  { timeZone: "Europe/Bratislava" }
)
// ✅ 2021-10-31 04:00:00 SEČ 

    Also, as for my previous comment, there is a wrong offset served for UTC 01:00 time when both timezones (browser's and selected one) are the same...

/**
 * Browser & selected time zone are equal to _Europe/Bratislava_
 */

getTimezoneOffset(
  "Europe/Bratislava", 
  utcToZonedTime("2021-10-31T01:00:00.000Z", "Europe/Bratislava")
) / (60 * 1000 * -1)
// -120
//  ❌ WRONG! that date refers to 2 AM in local time, which is having offset UTC+1
// DST change was just applied 

new Date('2022-03-27T01:00:00.000Z').getTimezoneOffset(),
// ✅ -60

format(new Date('2021-10-31T01:00:00.000Z'), 'yyyy-MM-dd HH:mm:ss zzz', { timeZone: "Europe/Bratislava" }),
// 2021-10-31 02:00:00 SEČ

/**
 * Browser & selected time zone are equal to _Europe/London
 */

getTimezoneOffset(
  "Europe/London", 
  utcToZonedTime("2021-10-31T01:00:00.000Z", "Europe/London")
) / (60 * 1000 * -1)
// -60
//  ❌ WRONG! that date refers to 1 AM in local time, aligned to GMT as DST change was just applied 

new Date('2022-03-27T01:00:00.000Z').getTimezoneOffset(),
// ✅ 0

format(new Date('2021-10-31T01:00:00.000Z'), 'yyyy-MM-dd HH:mm:ss zzz', { timeZone: "Europe/London" }),
// 2021-10-31 01:00:00 GMT 
marnusw commented 2 years ago

Thank you for the detailed investigation you've done @matthew-dev and for providing all this information. Many DST related issues have been fixed in 1.2.2, and it will be interesting to know whether some or all of these issues have gone away now.

Do you still have these tests to hand to check? It would be useful if you have time to create a PR with these tests (including failing ones) so we can track this more easily.

miromarchi commented 2 years ago

Hi. I experimented very similar issues with v1.2.2 and v1.3.0. In short, depending on the system date and the desired timezone, there is an issue in handling either the moment before DST or the moment after DST. Also, it seems that new issues are duplicate of this one, e.g. #154, #159, and #167

miromarchi commented 2 years ago

For example, this case tests the moment before DST kicks in. The original date is written in UTC format, and it is converted to Europe/Rome +01:00.

it('should handle DST for right before DST kicks in', () => {
  expect(
    format(utcToZonedTime('2022-03-27T00:59:59Z', 'Europe/Rome'), 'xxx', { timeZone: 'Europe/Rome' })
  ).toBe('+01:00');
});

In my opinion, if I understand correctly the rationale behind date-fns-tz, this test should be completely independent of the system time.

Do you think I am missing something, or is there a bug?

miromarchi commented 2 years ago

Also, isn't this issue very similar to the one that should be fixed in #108 and #93?

Gadzy commented 2 years ago

@marnusw Any update regarding this issue ? Do you need a PR from us ?

marnusw commented 2 years ago

@Gadzy I'm quite short on time at the moment, so yes, please put together a PR if you can. If you would add / update tests to show the problem, and then implement the solution in a subsequent commit that will help a lot with making the review a quick one.