dayjs icon indicating copy to clipboard operation
dayjs copied to clipboard

Add days across Daylight Saving Time

Open MarkSFrancis opened this issue 5 years ago • 13 comments

When adding days to a time, it's assuming each day is 24 hours long. This is not a correct assumption when crossing over daylight saving time.

Steps to recreate

import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import tz from 'dayjs/plugin/timezone';

dayjs.extend(utc);
dayjs.extend(tz);

// 2020-10-25 is when DST starts in Europe/London (clocks go back by one hour). 
// This means that 2020-10-25 is a day that's only 23 hours long
const date1 = dayjs.tz('2020-10-24', 'Europe/London').add(2, 'day').toISOString();
const date2 = dayjs.tz('2020-10-26', 'Europe/London').toISOString();

// Prints
// {
//   date1: '2020-10-25T23:00:00.000Z',
//   date2: '2020-10-26T00:00:00.000Z'
// }
console.log({ date1, date2 });

Expected behavior date1 and date2 should be the same, as adding the number of "days" to the date should've allowed for the fact that not all days are 24 hours.

Information

  • Day.js Version: v1.9.2
  • OS: Windows 10 20H2
  • Browser: Chromium: 87.0.4280.101
  • Time zone: (UTC -04:00) Santiago

MarkSFrancis avatar Dec 14 '20 10:12 MarkSFrancis

This is an important issue for us, but there is a workaround possible...

  1. Get the date's TZ
  2. Add days using date method
  3. Use tz method to "reset" TZ (date method doesn't update the offset value)
export const addDaysExtended = (dateWithTZ, days) => {
  const clone = dayjs(dateWithTZ);
  const tz = clone.$x.$timezone;
  return clone.date(clone.date() + days).tz(tz, true);
};

const date1 = dayjs.tz('2020-10-24', 'Europe/London').add(2, 'day').toISOString();
const date2 = dayjs.tz('2020-10-26', 'Europe/London').toISOString();
const date3 = addDaysExtended(dayjs.tz('2020-10-24', 'Europe/London'), 2).toISOString();

console.log({ date1, date2, date3 });
// {
//    date1: '2020-10-25T23:00:00.000Z',
//    date2: '2020-10-26T00:00:00.000Z',
//    date3: '2020-10-26T00:00:00.000Z'
// }

moulinraphael avatar Feb 03 '21 14:02 moulinraphael

I suppose this is the reason why I get wrong dates using .startOf()/.endOf() After the daylight saving time changed a few days ago here in Greece ('Athens/Europe' timezone, normally GMT+2, but changes to GMT+3 when daylight saving time is applied).

I am calculating the "this year" time period as follows:

from = dayjs().tz(timeZone, true).startOf('year'); to = from.clone().endOf('year').endOf('day');

This used to work before the daylight saving time was applied, but right now it produces wrong results: from -> "2020-12-31T21:00:00.000Z" to -> "2020-12-31T20:59:59.999Z"

nemphys avatar Apr 06 '21 12:04 nemphys

We completely switched our project to dayjs and at the end noticed that dayjs does not support the daylight saving time. I can only warn against using this library. Operations such as add cause unexpected results. As soon as a calculation goes over the limit of the time change, the result is wrong. We will continue to use moment.js. This library can only be used in countries without daylight saving time. Example:

dayjs('25-10-2020', 'DD-MM-YYYY') .tz('Europe/Berlin') .add(1, 'day') .diff(dayjs('26-10-2020', 'DD-MM-YYYY').tz('Europe/Berlin'), 'days') .toString()

result = -3600000

ChristianKlima avatar Aug 30 '21 11:08 ChristianKlima

Running into this issue as well, where I'm trying to calculate the start of a given isoWeek. When crossing DST, the calculation starts returning strange results.

rickpastoor avatar Sep 28 '21 14:09 rickpastoor

We are seeing this issue too. Migrating from momentjs, where we already had unit tests for date conversions across daylight savings. Our tests are now showing off by 1 hour errors.

mr-short avatar Oct 11 '21 21:10 mr-short

FYI, I have created a dayjs plugin to solve this issue. https://www.npmjs.com/package/dayjs-timezone-iana-plugin

The code is quite simple and largely inspired by moment-timezone. It includes the complete and latest IANA databases 2021e (96KB compressed). It takes more space than the method used by DayJS or Luxon. But it works even if NodeJS is compiled with small-icu.

We will update it regularly and we used it already in production.

Be careful, dayjs.tz() is not implemented (feel free to make a PR), you must use dayjs(...).tz():

const date1 = dayjs('2020-10-24').tz('Europe/London').add(2, 'day').toISOString();
const date2 = dayjs('2020-10-26').tz('Europe/London').toISOString();

dgrelaud avatar Dec 08 '21 06:12 dgrelaud

Looks like dayjs used to behave this way but was "fixed" in #586.

camsteffen avatar Mar 30 '22 22:03 camsteffen

Still seems to behave this way

AngelFHC avatar Nov 29 '22 02:11 AngelFHC

Any update on this? Still behaving like this:

dayjs('25-10-2020', 'DD-MM-YYYY') .tz('Europe/Berlin') .add(1, 'day') .diff(dayjs('26-10-2020', 'DD-MM-YYYY').tz('Europe/Berlin'), 'days') .toString() result = -3600000

https://github.com/iamkun/dayjs/issues/1271#issuecomment-908255838

autonomobil avatar Feb 07 '23 15:02 autonomobil

I just opened an issue that looks similar to this.

bbecker-te avatar Feb 14 '23 15:02 bbecker-te

Hello everyone,

I'd like to inform you that my team is currently in the process of transitioning from using "moment" to "dayjs". Our objective is to calculate the addition of days within a specific timezone while ensuring that the hour remains consistent even during daylight saving periods.

We've noticed that the dayjs ".add" function essentially appends a certain amount of time. Although it appears to handle hour changes correctly, we've encountered issues with the accuracy of the returned ISO string and the "date" function.

We ended with a small plugin, to add, substract correctly in a specific timezone.

// @ts-nocheck
import { PluginFunc } from 'dayjs'

const plugin: PluginFunc = (_, dayjsClass, d) => {
	// eslint-disable-next-line no-param-reassign, func-names
	dayjsClass.prototype.addInTz = function (...args) {
		const timezone = this.$x.$timezone
		if (!timezone) {
			throw new Error('No timezone set')
		}
		return d.tz(
			d(this.toDate())
				.tz(timezone)
				.add(...args)
				.format('YYYY-MM-DD HH:mm:ss'),
			timezone,
		)
	}

	// eslint-disable-next-line no-param-reassign, func-names
	dayjsClass.prototype.subtractInTz = function (...args) {
		const timezone = this.$x.$timezone
		if (!timezone) {
			throw new Error('No timezone set')
		}
		return d.tz(
			d(this.toDate())
				.tz(timezone)
				.subtract(...args)
				.format('YYYY-MM-DD HH:mm:ss'),
			timezone,
		)
	}
}

export default plugin

Some tests examples ( all pass)

import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
// eslint-disable-next-line import/no-named-as-default
import dayjsTzCalc from '.'

dayjs.extend(utc)
dayjs.extend(timezone)
dayjs.extend(dayjsTzCalc)

describe('dayjsTzCalc', () => {
	it('should add in the tz taking in count saving day light', () => {
		// Us change their time zone on 2021-03-14
		const originalDate = dayjs('2021-03-12T00:00:00.000Z').tz(
			'America/New_York',
		)

		expect(originalDate.hour()).toEqual(19)

		// We now add 31 days
		const dateToCheck = originalDate.addInTz(31, 'day')

		// We expect to have a shift of 1 hour due to saving day light
		expect(dateToCheck.toISOString()).toEqual('2021-04-11T23:00:00.000Z')
		// We expect the same hour as the original date
		expect(dateToCheck.hour()).toEqual(19)
	})

	it('should subtract in the tz taking in count saving day light', () => {
		// Us change their time zone on 2021-03-14
		const originalDate = dayjs('2021-04-12T23:00:00.000Z').tz(
			'America/New_York',
		)

		expect(originalDate.hour()).toEqual(19)

		// We now add 31 days
		const dateToCheck = originalDate.subtractInTz(31, 'day')

		// We expect to have a shift of 1 hour due to saving day light
		expect(dateToCheck.toISOString()).toEqual('2021-03-13T00:00:00.000Z')
		// We expect the same hour as the original date
		expect(dateToCheck.hour()).toEqual(19)
	})

	it('should work across daylight saving time', () => {
		const originalDate = dayjs('2021-03-12T00:00:00.000Z').tz(
			'America/New_York',
		)

		expect(originalDate.hour()).toEqual(19)

		const dateToCheck = originalDate.addInTz(8, 'month')

		expect(dateToCheck.toISOString()).toEqual('2021-11-12T00:00:00.000Z')
		expect(dateToCheck.hour()).toEqual(19)

		const dateToCheck2 = dateToCheck.subtractInTz(8, 'month')
		expect(dateToCheck2.toISOString()).toEqual('2021-03-12T00:00:00.000Z')
		expect(dateToCheck2.hour()).toEqual(19)
	})
})

Little note, it should be possible to overload the "add" and "substract" original function, and detect a current timezone ( or not) to handle the correct addition, but in my team we prefer to tell clearly that we perform an addition/substraction in a TZ or directly on the UTC ( without daylight savings effects)

Moumouls avatar Aug 21 '23 15:08 Moumouls

To solve this issue that @MarkSFrancis posted we should do the manipulation before apply the timezone, for some reason the .tz function from Day.js is not exactly the same as Moment.js so we can have different results.

On Day.js docs they explain how it works: https://day.js.org/docs/en/plugin/timezone

Try this:

import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import tz from 'dayjs/plugin/timezone';
 
dayjs.extend(utc);
dayjs.extend(tz);
 
// Do calculation (add) before apply timezone
// The 'true' flag after timezone 'Europe/London' means that I want to treat the current datetime as local time see docs.
const date1 = dayjs('2020-10-24').add(2, 'day').tz('Europe/London', true).toISOString();
const date2 = dayjs.tz('2020-10-26', 'Europe/London').toISOString();
 
// Prints
// {
//   date1: '2020-10-26T00:00:00.000Z',
//   date2: '2020-10-26T00:00:00.000Z'
// }
console.log({ date1, date2 });

Both dates are now correct considering the daylight savings.

eleandrodosreis avatar Feb 01 '24 18:02 eleandrodosreis

The original code works if you run it in Europe/London. Here's the fiddle. Open the dev tools, go to Sensors, set Location to London, and click Run - the dates will match. It seems to work with other locations that follow the same DST schedule, like Berlin or Europe/Paris.

@MarkSFrancis tested in Santiago, Chile where DST ends in September. My guess is because DST is timed differently in America/Santiago, the results don't match. Same if you run the code in a TZ that doesn't observe DST.

alex996 avatar Jun 07 '24 14:06 alex996

@alex996 observed the same. It works for European countries observing DST but only breaks for USA. Is there any handling clients can do for USA?

Aki0x137 avatar Jul 22 '24 05:07 Aki0x137