Moment: Adding and subtracting from dates does not preserve hours when the timezone has midnight DST rules

Created on 22 Aug 2018  ·  8Comments  ·  Source: moment/moment

Description of the Issue:

I'm trying to create a date range in (array of moment dates) where each date is 1 day after the previous. This works as expected for all users, but we've gotten a bug report for a user in the America/Santiago timezone who is seeing duplicated dates in the UI. What I noticed is that adding 1 day to a particular date is actually only adding 23 hours.

With a date on 8/11/2018 00:00:00 in America/Santiago timezone, adding 1 day increases the time to 8/11/2018 23:00:00 instead of 8/12/2018 00:00:00.

The docs explain that the hours should be preserved when shifting across a DST _while using days_, but that does not appear to be true here.

There are also special considerations to keep in mind when adding time that crosses over daylight saving time. If you are adding years, months, weeks, or days, the original hour will always match the added hour.

// This code is from moment.js docs https://momentjs.com/docs/#/manipulating/add/
var m = moment(new Date(2011, 2, 12, 5, 0, 0)); // the day before DST in the US
m.hours(); // 5
m.add(1, 'days').hours(); // 5

Steps to Reproduce

In the following code, the timezone being used has it's DST rule on midnight. I have a date that is on midnight the day before this rule kicks in. I added 1 day to this date, and expect that it should be the following day at midnight/0 hours (hours should be preserved), but it's actually only adding 23 hours.

fmt = d => d.format() + ' ' + d.tz()

x = moment.tz(new Date('08/11/2018 00:00:00'), 'America/Santiago');
fmt(x);                       // "2018-08-11T00:00:00-04:00 America/Santiago"
fmt(x.clone().add(1, 'day')); // "2018-08-11T23:00:00-04:00 America/Santiago" - offset unchanged, added 23 hours not 1 day
fmt(x.clone().add(2, 'day')); // "2018-08-13T00:00:00-03:00 America/Santiago" - original hour preserved now

Here, you can see that adding 24 hours shifted it up by 25 hours and the timezone offset was changed.

fmt = d => d.format() + ' ' + d.tz()

x = moment.tz(new Date('08/11/2018 00:00:00'), 'America/Santiago');
fmt(x);                          // "2018-08-11T00:00:00-04:00 America/Santiago"
fmt(x.clone().add(24, 'hours')); // "2018-08-12T01:00:00-03:00 America/Santiago" - offset changed, added 25 hours
fmt(x.clone().add(48, 'hours')); // "2018-08-13T01:00:00-03:00 America/Santiago"

Environment:

  • Version 68.0.3440.84 (Official Build) (64-bit)
  • Mac OSX El Capitan 10.11.6 (15G31)

Other information that may be helpful:

  • Machine Timezone: US Eastern Timezone, Daylight Savings Time
  • Time & Date Code was run: 3:00 pm on August 22, 2018
  • I reproduced this issue in Chrome Dev Tools on Moment.js web page

JS Date Output

(new Date()).toString()

  • Wed Aug 22 2018 15:13:40 GMT-0400 (Eastern Daylight Time)

(new Date()).toLocaleString()

  • 8/22/2018, 3:13:40 PM

(new Date()).getTimezoneOffset()

  • 240

navigator.userAgent

  • Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.84 Safari/537.36

moment.version

  • 2.22.2
Bug DST Pending Next Release Up-For-Grabs

Most helpful comment

The behavior here looks like it was changed between moment-timezone v0.5.4 and v0.5.26.
In the old version, adding 1 day to '2018-08-11' gave you 11pm on same day
In new version, it gives you '2018-08-12' at midnight. But this time does not actually exist, and adding 1 minute goes back an hour - but this DST change was supposed to be adding 1 hour.

Old version, behaves as described in this issue:
https://jsfiddle.net/eqyvuxht/1/

New version, changed behavior, but still wrong I think?
https://jsfiddle.net/0h6atn4b/4/

Here is a workaround:

function switchZone(m, zone) {
  let arr = [m.year(), m.month(), m.date(), m.hour(), m.minute(), m.second()];
  if(zone) {
    return moment.tz(arr, zone);
  }
  return moment(arr);
}

function safeAddDays(m, days) {
   let oldZone = m.tz();
   let utc = switchZone(m, 'UTC');
   utc.add(days, 'days');
   return switchZone(utc, oldZone);
}

All 8 comments

I tried to reproduce this in Europe/Rome timezone, and was unable to:

Europe/Rome - https://www.timeanddate.com/time/change/italy/rome

Oct 28, 2018 - Daylight Saving Time Ends
When local daylight time is about to reach
Sunday, October 28, 2018, 3:00:00 am clocks are turned backward 1 hour to
Sunday, October 28, 2018, 2:00:00 am local standard time instead.

There is no bug in this code.

d => d.format() + ' ' + d.tz()

x = moment.tz(new Date('10/27/2018 00:00:00'), 'Europe/Rome');
fmt(x)                       // "2018-10-27T06:00:00+02:00 Europe/Rome"
fmt(x.clone().add(1, 'day')) // "2018-10-28T06:00:00+01:00 Europe/Rome" - tz offset changed, hour preserved as expected
fmt(x.clone().add(2, 'day')) // "2018-10-29T06:00:00+01:00 Europe/Rome"

You can see that the TZ changes, but the hours remain constant.

Based on the reproduction steps, it looks like there is a discrepancy in the code when the date added lands perfectly onto the DST cutoff. In the Santiago timezone, the DST rule is that at midnight the clocks go back an hour.

It appears that this edge case may be specifically to timezones where the DST cutoff is at midnight, because I was unable to reproduce with a timezone where the cutoff is at 3am, with x date at 3am.

Europe/Rome - https://www.timeanddate.com/time/change/italy/rome

Oct 28, 2018 - Daylight Saving Time Ends
When local daylight time is about to reach
Sunday, October 28, 2018, 3:00:00 am clocks are turned backward 1 hour to
Sunday, October 28, 2018, 2:00:00 am local standard time instead.

There is no bug in this code.

fmt = d => d.format() + ' ' + d.tz()

x = moment.tz(new Date('October 27, 2018 03:00:00'), 'Europe/Rome');
fmt(x);                       // "2018-10-27T09:00:00+02:00 Europe/Rome"
fmt(x.clone().add(1, 'days')) // "2018-10-28T09:00:00+01:00 Europe/Rome" - tz offset changed, hour preserved as expected
fmt(x.clone().add(2, 'days')) // "2018-10-29T09:00:00+01:00 Europe/Rome"

I was able to confirm that this bug only exists for timezones when the DST cutoff is at midnight, and you are adding time to a date that causes the date to land exactly at the cutoff.

America/Punta_Arenas - https://www.timeanddate.com/time/zone/chile/punta-arenas
In 2016, this timezone also had a cutoff at midnight.

This code appears to have a bug. See comment after each line

fmt = d => d.format() + ' ' + d.tz()

x = moment.tz(new Date('08/13/2016 00:00:00'), 'America/Punta_Arenas')
fmt(x);                        // "2016-08-13T00:00:00-04:00 America/Punta_Arenas"
fmt(x.clone().add(1, 'days')); // "2016-08-13T23:00:00-04:00 America/Punta_Arenas" - 23 hours added, not 1 day, no tz offset change
fmt(x.clone().add(2, 'days')); // "2016-08-15T00:00:00-03:00 America/Punta_Arenas"

I found what appears to be a similar issue when subtracting over the same kinds of timezones (DST at midnight).

When subtracting the dates exactly onto a DST shift, the hour is not preserved on just that day, but the date is changed.

fmt = d => d.format() + ' ' + d.tz()

x = moment.tz(new Date('08/13/2018 23:00:00'), 'America/Santiago');
fmt(x);                             // "2018-08-14T00:00:00-03:00 America/Santiago"
fmt(x.clone().subtract(1, 'days')); // "2018-08-13T00:00:00-03:00 America/Santiago"
fmt(x.clone().subtract(2, 'days')); // "2018-08-12T01:00:00-03:00 America/Santiago" - hour not preserved, but date changed
fmt(x.clone().subtract(3, 'days')); // "2018-08-11T00:00:00-04:00 America/Santiago" - original hour preserved now

We received another report of this issue, from a user with America/Asuncion as their timezone.

The function that produces the bug in our application looks like this:

function generateDayRange(start, end) {
    const days = [];
    let current = start.clone();

    while (current <= end) {
        days.push(current.clone());
        current = current.add(1, 'days');
    }

    return days;
}

When we use this function, we pass in a start date that is at the beginning of the day, and an end date where the time is not as significant:

generateDayRange(startDate.clone().startOf('week'), startDate.clone().endOf('week'));
generateDayRange(getAppDate(startDate), getAppDate(endDate));
generateDayRange(startRange, startRange.clone().add(27, 'days'));

The issue is that the resulting array of moment dates contains a duplicate day, which is reflected in the UI of our application (a calendar has a duplicate day).

My larger concern is that since the library does not behave as expected, there may be more subtle issues going unnoticed, like a perceived loss of data, bad requests, etc.

I'll work to get a set of tests up in a codepen or something, so that curious readers can take a stab at solving this issue.

Related issue #4785 has links to code:
moment example https://runkit.com/embed/1r62d83amq7x
yet luxon handles this correctly
https://runkit.com/embed/t49achvensqf

I know there was recent DST changes, so we should check against the code currently on develop (but not released). Or, we can wait until the next release to see if things are still the same.

The behavior here looks like it was changed between moment-timezone v0.5.4 and v0.5.26.
In the old version, adding 1 day to '2018-08-11' gave you 11pm on same day
In new version, it gives you '2018-08-12' at midnight. But this time does not actually exist, and adding 1 minute goes back an hour - but this DST change was supposed to be adding 1 hour.

Old version, behaves as described in this issue:
https://jsfiddle.net/eqyvuxht/1/

New version, changed behavior, but still wrong I think?
https://jsfiddle.net/0h6atn4b/4/

Here is a workaround:

function switchZone(m, zone) {
  let arr = [m.year(), m.month(), m.date(), m.hour(), m.minute(), m.second()];
  if(zone) {
    return moment.tz(arr, zone);
  }
  return moment(arr);
}

function safeAddDays(m, days) {
   let oldZone = m.tz();
   let utc = switchZone(m, 'UTC');
   utc.add(days, 'days');
   return switchZone(utc, oldZone);
}

We are running into the same issue this month (Sept 2019) where our Santiago users using our date picker are seeing a calendar with dates going 1, 2, 3, 4, 5, 6, 7, 7, 7, 7, for the month of September... Under the hood, we're also using .add(1, 'days'). It will occur again next year in September 2020 if we don't have a fix for it by then.

@mblandfo workaround seems to do the trick for us, too. Thanks!

Was this page helpful?
0 / 5 - 0 ratings

Related issues

ghost picture ghost  ·  3Comments

ninigix picture ninigix  ·  3Comments

Delgan picture Delgan  ·  3Comments

danieljsinclair picture danieljsinclair  ·  3Comments

vbullinger picture vbullinger  ·  3Comments