diff --git a/src/index.js b/src/index.js index 061ade178..27c393ded 100644 --- a/src/index.js +++ b/src/index.js @@ -151,13 +151,27 @@ class Dayjs { Date.UTC(this.$y, m, d) : new Date(this.$y, m, d), this) return isStartOf ? ins : ins.endOf(C.D) } - const instanceFactorySet = (method, slice) => { + const instanceFactorySet = (method, slice, keepOffset) => { const argumentStart = [0, 0, 0, 0] const argumentEnd = [23, 59, 59, 999] - return Utils.w(this.toDate()[method].apply( // eslint-disable-line prefer-spread - this.toDate('s'), + const date = this.toDate('s') + // In UTC mode the setters are setUTC* and no wall clock is ambiguous. + const trackOffset = keepOffset && !this.$u + const oldOffset = trackOffset ? date.getTimezoneOffset() : 0 + const time = this.toDate()[method].apply( // eslint-disable-line prefer-spread + date, (isStartOf ? argumentStart : argumentEnd).slice(slice) - ), this) + ) + if (!trackOffset) return Utils.w(time, this) + // A Date setter resolves the wall clock that repeats at a DST fall back to + // its first occurrence, moving the result a whole offset step away from the + // instant it came from. Units below a day never leave the offset they + // started in, so pull the result back when that offset still holds there. + const newOffset = date.getTimezoneOffset() + const shifted = time + ((oldOffset - newOffset) * C.MILLISECONDS_A_MINUTE) + const keep = oldOffset === newOffset || + new Date(shifted).getTimezoneOffset() === oldOffset + return Utils.w(keep ? shifted : time, this) } const { $W, $M, $D } = this const utcPad = `set${this.$u ? 'UTC' : ''}` @@ -177,11 +191,11 @@ class Dayjs { case C.DATE: return instanceFactorySet(`${utcPad}Hours`, 0) case C.H: - return instanceFactorySet(`${utcPad}Minutes`, 1) + return instanceFactorySet(`${utcPad}Minutes`, 1, true) case C.MIN: - return instanceFactorySet(`${utcPad}Seconds`, 2) + return instanceFactorySet(`${utcPad}Seconds`, 2, true) case C.S: - return instanceFactorySet(`${utcPad}Milliseconds`, 3) + return instanceFactorySet(`${utcPad}Milliseconds`, 3, true) default: return this.clone() } diff --git a/test/timezone.test.js b/test/timezone.test.js index 42cab8934..fd83e07b9 100644 --- a/test/timezone.test.js +++ b/test/timezone.test.js @@ -80,3 +80,35 @@ it('UTC diff in DST', () => { expect(day1.diff(day2, 'd')) .toBe(-3) }) + +// The wall clock right after a fall back is the one that just happened, so the +// Date setters startOf/endOf rely on can resolve it to the earlier offset and +// drop the result a whole hour. Only one hour fall backs are collected, so that +// the repeated wall clock covers a whole hour; a host without DST yields none. +const fallBacks = () => { + const result = [] + let previous = new Date(Date.UTC(2015, 0, 1)).getTimezoneOffset() + for (let t = Date.UTC(2015, 0, 1); t < Date.UTC(2030, 0, 1); t += 3600000) { + const offset = new Date(t).getTimezoneOffset() + if (offset - previous === 60) result.push(t) // the clock was moved back here + previous = offset + } + return result +} + +it('startOf/endOf below a day in a repeated hour (DST)', () => { + fallBacks().forEach((transition) => { + const time = transition + (15 * 60 * 1000) + 45123 // inside the repeated hour + const dayjsTest = dayjs(time) + const momentTest = moment(time); + ['hour', 'minute', 'second'].forEach((unit) => { + expect(dayjsTest.startOf(unit).valueOf()).toBe(momentTest.clone().startOf(unit).valueOf()) + expect(dayjsTest.endOf(unit).valueOf()).toBe(momentTest.clone().endOf(unit).valueOf()) + expect(dayjsTest.isSame(dayjsTest, unit)).toBe(true) + }) + // startOf only clears the fields below its unit, it never moves the clock + expect(time - dayjsTest.startOf('second').valueOf()).toBe(dayjsTest.millisecond()) + expect(time - dayjsTest.startOf('minute').valueOf()) + .toBe((dayjsTest.second() * 1000) + dayjsTest.millisecond()) + }) +})