From 16581d7a6467023ce34aaf9f9c3e3d3c075cd190 Mon Sep 17 00:00:00 2001 From: Ferdinand Prantl Date: Wed, 5 Sep 2018 09:06:39 +0200 Subject: [PATCH] Introduce an UTC mode Additions: * Constructor accepts a boolean flag to enable the UTC mode: { utc: true }. * Method utc() returns a clone of the instance in the UTC mode. * Method local() returns a clone of the instance in the non-UTC (local) mode. * Method isUTC() checks, if the instance is in the UITC mode. * Method utcOffset() returns the time zone offset to UTC consistently with Date.prototype.getTimezoneOffset() Differences to the "normal" (local) dayjs mode: * Assume UTC as the time zone of the string passed to the constructor - for example, "2018-10-28" will be parsed as "2018-10-28T00:00:00Z". *But only if the time zone is omitted.* If you end the string tith a TZ offset - "2018-10-28T00:00:00+02:00", it will be retained and the whole date will be converted to UTC, when iniitalizing the datejs object. * Methods returning the date parts like year(), month() etc. return UTC parts using Date.propertotype.getUTC* methods. * Methods manipulating the date - set() - work with the UTC values of the date parts, as the getters above. * The format() method always formats the time zone as "+00:00". * The utcOffset() method always returns zero. --- docs/en/API-reference.md | 91 +++++++++++++++++++++++++++ index.d.ts | 10 ++- src/index.js | 126 +++++++++++++++++++++++++++---------- test/display.test.js | 5 ++ test/get-set.test.js | 64 +++++++++++++++++++ test/index.d.test.ts | 8 +++ test/manipulate.test.js | 8 +++ test/parse.test.js | 14 +++++ test/utc/isUTC.test.js | 20 ++++++ test/utc/local.test.js | 23 +++++++ test/utc/utc.test.js | 23 +++++++ test/utc/utcOffset.test.js | 21 +++++++ 12 files changed, 379 insertions(+), 34 deletions(-) create mode 100644 test/utc/isUTC.test.js create mode 100644 test/utc/local.test.js create mode 100644 test/utc/utc.test.js create mode 100644 test/utc/utcOffset.test.js diff --git a/docs/en/API-reference.md b/docs/en/API-reference.md index c3911a2d9..7ae6bb56d 100644 --- a/docs/en/API-reference.md +++ b/docs/en/API-reference.md @@ -45,6 +45,12 @@ The `Dayjs` object is immutable, that is, all API operations that change the `Da - [Is Same `.isSame(compared: Dayjs)`](#is-same-issamecompared-dayjs) - [Is After `.isAfter(compared: Dayjs)`](#is-after-isaftercompared-dayjs) - [Is a Dayjs `.isDayjs()`](#is-a-dayjs-isdayjscompared-any) + - [UTC Mode](#utc-mode) + - [Date-Only Mode`](#date-only-mode) + - [Is UTC `.isUTC()`](#is-utc-isutc) + - [To UTC `.utc() | dayjs(original: Dayjs, { utc: true })`](#to-utc-utcdayjsoriginal-dayjs-utc-true) + - [To Local `.local() | dayjs(original: Dayjs, { utc: false })`](#to-local-localdayjsoriginal-dayjs-utc-true) + - [UTC Offset `.utcOffset()`](#utc-offset-utcoffset) - [Plugin APIs](#plugin-apis) - [RelativeTime](#relativetime) - [IsLeapYear](#isleapyear) @@ -403,6 +409,91 @@ dayjs.isDayjs(dayjs()); // true dayjs.isDayjs(new Date()); // false ``` +## UTC + +`Day.js` instances can be initialized using a string with any time zone offset. However, the native JavaScript `Date` object used internally works only in the local time zone with the support of accessing the object value in UTC too. If no time zone is specified, the local time zone is assumed. `Day.js` follows the same principle. + +`Day.js` instances can be initialized in the *UTC mode*, which makes getters, setters and formatting methods use the UTC properties of the `Date` object. It can be useful to: + +* Initialize and use `Day.js` instances always in UTC, without specifying the time zone explicitly. +* Initialize and use `Day.js` instances in a *date-only mode*. + +### Date-only Mode + +Sometimes only the day is important; not the time. If a `Day.js` instance or a `Date` object itself is initialized with the date part only, the constructor assumes a full UTC date and automatically adds the time part: + +```js +const date = dayjs('2018-09-07') +new Date('2018-09-07') +// Both assume an input "2018-09-07T00:00:00Z". +// Both initialize the date to "2018-09-07 02:00:00 +02:00" in Central Europe. +const day = date.date() // Returns 7, OK. +const day = date.hour() // Returns 2, but well, we do not use the time part. +``` + +Because the input is assumed in UTC, when converting to the local time zone, which is used by default in both `Day.js` instances and `Date` objects, a time zone conversion will be performed. It can change the value of the day, if the time zone offset from UTC is negative, which may be a problem: + +```js +const date = dayjs('2018-09-07') +new Date('2018-09-07') +// Both assume an input "2018-09-07T00:00:00Z". +// Both initialize the date to "2018-09-06 18:00:00 -06:00" in North-East America. +const day = date.date() // Returns 6, a bad surprise. +const day = date.hour() // Returns 18, but well, we do not use the time part. +``` + +Switching the `Day.js` instance to the UTC mode will not change the initialization or value of the internal `Date` object, so that any date computations and comparisons with other `Day.js` instances will be correct. However, getters, setters and formatting methods will use date parts in UTC, making at appear, that no conversion to the local time zone took place. + +```js +const date = dayjs('2018-09-07', { utc: true }) +const date = dayjs('2018-09-07').utc() +// Both assume an input "2018-09-07T00:00:00Z". +// Both initialize the date to "2018-09-06 18:00:00 -06:00" in North-East America. +// Both remember to use UTC date parts on the interface of Day.js. +const day = date.date() // Returns 7, OK. +const day = date.hour() // Returns 0, OK; well, we do not use the time part. +``` + +### Is UTC `.isUTC()` + +Returns a `boolean` indicating whether the `Dayjs` instance works in the UTC mode or not. + +```js +dayjs().isUTC(); // Returns false +dayjs().utc().isUTC(); // Returns true +``` + +### To UTC `.utc() | dayjs(original: Dayjs, { utc: true })` + +Returns a cloned `Dayjs` instance in the UTC mode. The UTC mode will be retained, when cloning the `Day.js` instance further, unless `utc: false` is specified. + +```js +dayjs().utc(); +dayjs('2019-01-25', { utc: true }); +dayjs().utc().clone(); // continues in the UTC mode +dayjs(dayjs('2019-01-25', { utc: true })); // continues in the UTC mode +``` + +### To Local `.local() | dayjs(original: Dayjs, { utc: false })` + +Returns a cloned `Dayjs` instance in the local time zone mode. The local time zone mode will be retained, when cloning the `Day.js` instance further, unless `utc: true` is specified. It is also the default mode of constructing a new `Day.js` instance. + +```js +dayjs('2019-01-25', { utc: true }).local(); +dayjs('2019-01-25 15:43', { utc: false }); // default, not necessary +dayjs('2019-01-25', { utc: true }).local().clone(); // continues in the local mode +dayjs(dayjs().utc().local()); // continues in the local mode +``` + +### UTC Offset `.utcOffset()` + +Returns an offset of the `Dayjs`'s instance to UTC in minutes. It is a negative offset like `Date.prototype.getTimezoneOffset` returns it. If it is *added* to a zoned time, a time in UTC will be obtained. + +```js +const date = dayjs('2019-01-25 15:43'); +const offset = date.utcOffset() // Returns -60 in Central Europe +``` + ## Plugin APIs ### RelativeTime diff --git a/index.d.ts b/index.d.ts index 94854e460..2d272ffa3 100644 --- a/index.d.ts +++ b/index.d.ts @@ -5,7 +5,7 @@ declare function dayjs (config?: dayjs.ConfigType, option?: dayjs.OptionType): d declare namespace dayjs { export type ConfigType = string | number | Date | Dayjs - export type OptionType = { locale: string } + export type OptionType = { locale: string, utc: boolean } export type UnitType = 'millisecond' | 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year' | 'date' @@ -83,6 +83,14 @@ declare namespace dayjs { isLeapYear(): boolean locale(arg1: any, arg2?: any): Dayjs + + utc(): Dayjs + + local(): Dayjs + + isUTC(): boolean + + utcOffset(): number } export type PluginFunc = (option: ConfigType, d1: Dayjs, d2: Dayjs) => void diff --git a/src/index.js b/src/index.js index e34a920ff..5e20a071d 100644 --- a/src/index.js +++ b/src/index.js @@ -36,14 +36,15 @@ const dayjs = (date, c) => { return new Dayjs(cfg) // eslint-disable-line no-use-before-define } -const wrapper = (date, instance) => dayjs(date, { locale: instance.$L }) +const wrapper = (date, instance) => dayjs(date, { locale: instance.$L, utc: instance.$u }) const Utils = U // for plugin use Utils.parseLocale = parseLocale Utils.isDayjs = isDayjs Utils.wrapper = wrapper -const parseDate = (date) => { +const parseDate = (cfg) => { + const { date } = cfg let reg if (date === null) return new Date(NaN) // Treat null as an invalid date if (Utils.isUndefined(date)) return new Date() @@ -53,10 +54,16 @@ const parseDate = (date) => { && (/.*[^Z]$/i.test(date)) // looking for a better way && (reg = date.match(C.REGEX_PARSE))) { // 2018-08-08 or 20180808 - return new Date( - reg[1], reg[2] - 1, reg[3] || 1, - reg[5] || 0, reg[6] || 0, reg[7] || 0, reg[8] || 0 - ) + const year = reg[1] + const month = reg[2] - 1 + const day = reg[3] || 1 + const hours = reg[5] || 0 + const minutes = reg[6] || 0 + const seconds = reg[7] || 0 + const milliseconds = reg[8] || 0 + return cfg && cfg.utc + ? new Date(Date.UTC(year, month, day, hours, minutes, seconds, milliseconds)) + : new Date(year, month, day, hours, minutes, seconds, milliseconds) } return new Date(date) // timestamp } @@ -67,19 +74,33 @@ class Dayjs { } parse(cfg) { - this.$d = parseDate(cfg.date) + this.$d = parseDate(cfg) this.init(cfg) } init(cfg) { - this.$y = this.$d.getFullYear() - this.$M = this.$d.getMonth() - this.$D = this.$d.getDate() - this.$W = this.$d.getDay() - this.$H = this.$d.getHours() - this.$m = this.$d.getMinutes() - this.$s = this.$d.getSeconds() - this.$ms = this.$d.getMilliseconds() + const utc = cfg && !!cfg.utc + const date = this.$d + this.$u = utc + if (utc) { + this.$y = date.getUTCFullYear() + this.$M = date.getUTCMonth() + this.$D = date.getUTCDate() + this.$W = date.getUTCDay() + this.$H = date.getUTCHours() + this.$m = date.getUTCMinutes() + this.$s = date.getUTCSeconds() + this.$ms = date.getUTCMilliseconds() + } else { + this.$y = date.getFullYear() + this.$M = date.getMonth() + this.$D = date.getDate() + this.$W = date.getDay() + this.$H = date.getHours() + this.$m = date.getMinutes() + this.$s = date.getSeconds() + this.$ms = date.getMilliseconds() + } this.$L = this.$L || parseLocale(cfg.locale, null, true) || L } @@ -152,14 +173,20 @@ class Dayjs { startOf(units, startOf) { // startOf -> endOf const isStartOf = !Utils.isUndefined(startOf) ? startOf : true const unit = Utils.prettyUnit(units) + const setMethods = this.$u + ? ['setUTCHours', 'setUTCMinutes', 'setUTCSeconds', 'setUTCMilliseconds'] + : ['setHours', 'setMinutes', 'setSeconds', 'setMilliseconds'] const instanceFactory = (d, m) => { - const ins = wrapper(new Date(this.$y, m, d), this) + const date = this.$u + ? new Date(Date.UTC(this.$y, m, d)) + : new Date(this.$y, m, d) + const ins = wrapper(date, this) return isStartOf ? ins : ins.endOf(C.D) } - const instanceFactorySet = (method, slice) => { + const instanceFactorySet = (slice) => { const argumentStart = [0, 0, 0, 0] const argumentEnd = [23, 59, 59, 999] - return wrapper(this.toDate()[method].apply( // eslint-disable-line prefer-spread + return wrapper(this.toDate()[setMethods[slice]].apply( // eslint-disable-line prefer-spread this.toDate(), isStartOf ? argumentStart.slice(slice) : argumentEnd.slice(slice) ), this) @@ -176,13 +203,13 @@ class Dayjs { instanceFactory(this.$D + (6 - this.$W), this.$M) case C.D: case C.DATE: - return instanceFactorySet('setHours', 0) + return instanceFactorySet(0) case C.H: - return instanceFactorySet('setMinutes', 1) + return instanceFactorySet(1) case C.MIN: - return instanceFactorySet('setSeconds', 2) + return instanceFactorySet(2) case C.S: - return instanceFactorySet('setMilliseconds', 3) + return instanceFactorySet(3) default: return this.clone() } @@ -194,30 +221,49 @@ class Dayjs { $set(units, int) { // private set const unit = Utils.prettyUnit(units) + const setMethods = this.$u + ? { + year: 'setUTCFullYear', + month: 'setUTCMonth', + date: 'setUTCDate', + hours: 'setUTCHours', + minutes: 'setUTCMinutes', + seconds: 'setUTCSeconds', + milliseconds: 'setUTCMilliseconds' + } + : { + year: 'setFullYear', + month: 'setMonth', + date: 'setDate', + hours: 'setHours', + minutes: 'setMinutes', + seconds: 'setSeconds', + milliseconds: 'setMilliseconds' + } switch (unit) { case C.D: - this.$d.setDate(this.$D + (int - this.$W)) + this.$d[setMethods.date](this.$D + (int - this.$W)) break case C.DATE: - this.$d.setDate(int) + this.$d[setMethods.date](int) break case C.M: - this.$d.setMonth(int) + this.$d[setMethods.month](int) break case C.Y: - this.$d.setFullYear(int) + this.$d[setMethods.year](int) break case C.H: - this.$d.setHours(int) + this.$d[setMethods.hours](int) break case C.MIN: - this.$d.setMinutes(int) + this.$d[setMethods.minutes](int) break case C.S: - this.$d.setSeconds(int) + this.$d[setMethods.seconds](int) break case C.MS: - this.$d.setMilliseconds(int) + this.$d[setMethods.milliseconds](int) break default: break @@ -226,7 +272,6 @@ class Dayjs { return this } - set(string, int) { return this.clone().$set(string, int) } @@ -272,10 +317,9 @@ class Dayjs { return this.add(number * -1, string) } - format(formatStr) { const str = formatStr || C.FORMAT_DEFAULT - const zoneStr = Utils.padZoneStr(this.$d.getTimezoneOffset()) + const zoneStr = this.$u ? '+00:00' : Utils.padZoneStr(this.$d.getTimezoneOffset()) const locale = this.$locale() const { weekdays, months @@ -435,6 +479,22 @@ class Dayjs { toString() { return this.$d.toUTCString() } + + isUTC() { + return this.$u + } + + utc() { + return dayjs(this.$d.valueOf(), { locale: this.$L, utc: true }) + } + + local() { + return dayjs(this.$d.valueOf(), { locale: this.$L, utc: false }) + } + + utcOffset() { + return this.$u ? 0 : this.$d.getTimezoneOffset() + } } dayjs.extend = (plugin, option) => { diff --git a/test/display.test.js b/test/display.test.js index 29024b9f3..bf84e723f 100644 --- a/test/display.test.js +++ b/test/display.test.js @@ -114,6 +114,11 @@ it('Format Escaping characters', () => { expect(dayjs().format(string)).toBe(moment().format(string)) }) +it('Formats an UTC instance to UTC time zone', () => { + const instance = dayjs('2018-09-06T19:34:28Z', { utc: true }) + expect(instance.format()).toEqual('2018-09-06T19:34:28+00:00') +}) + describe('Difference', () => { it('empty -> default milliseconds', () => { const dateString = '20110101' diff --git a/test/get-set.test.js b/test/get-set.test.js index af5676a38..68c464df0 100644 --- a/test/get-set.test.js +++ b/test/get-set.test.js @@ -42,6 +42,38 @@ it('Millisecond', () => { expect(dayjs().millisecond()).toBe(moment().millisecond()) }) +it('UTC Year', () => { + expect(dayjs().utc().year()).toBe(moment().utc().year()) +}) + +it('UTC Month', () => { + expect(dayjs().utc().month()).toBe(moment().utc().month()) +}) + +it('UTC Day of Week', () => { + expect(dayjs().utc().day()).toBe(moment().utc().day()) +}) + +it('UTC Date', () => { + expect(dayjs().utc().date()).toBe(moment().utc().date()) +}) + +it('UTC Hour', () => { + expect(dayjs().utc().hour()).toBe(moment().utc().hour()) +}) + +it('UTC Minute', () => { + expect(dayjs().utc().minute()).toBe(moment().utc().minute()) +}) + +it('UTC Second', () => { + expect(dayjs().utc().second()).toBe(moment().utc().second()) +}) + +it('UTC Millisecond', () => { + expect(dayjs().utc().millisecond()).toBe(moment().utc().millisecond()) +}) + it('Set Day', () => { expect(dayjs().set('date', 30).valueOf()).toBe(moment().set('date', 30).valueOf()) }) @@ -74,6 +106,38 @@ it('Set Millisecond', () => { expect(dayjs().set('millisecond', 999).valueOf()).toBe(moment().set('millisecond', 999).valueOf()) }) +it('Set UTC Day', () => { + expect(dayjs().utc().set('date', 30).valueOf()).toBe(moment().utc().set('date', 30).valueOf()) +}) + +it('Set UTC Day of Week', () => { + expect(dayjs().utc().set('day', 0).valueOf()).toBe(moment().utc().set('day', 0).valueOf()) +}) + +it('Set UTC Month', () => { + expect(dayjs().utc().set('month', 11).valueOf()).toBe(moment().utc().set('month', 11).valueOf()) +}) + +it('Set UTC Year', () => { + expect(dayjs().utc().set('year', 2008).valueOf()).toBe(moment().utc().set('year', 2008).valueOf()) +}) + +it('Set UTC Hour', () => { + expect(dayjs().utc().set('hour', 6).valueOf()).toBe(moment().utc().set('hour', 6).valueOf()) +}) + +it('Set UTC Minute', () => { + expect(dayjs().utc().set('minute', 59).valueOf()).toBe(moment().utc().set('minute', 59).valueOf()) +}) + +it('Set UTC Second', () => { + expect(dayjs().utc().set('second', 59).valueOf()).toBe(moment().utc().set('second', 59).valueOf()) +}) + +it('Set UTC Millisecond', () => { + expect(dayjs().utc().set('millisecond', 999).valueOf()).toBe(moment().utc().set('millisecond', 999).valueOf()) +}) + it('Set Unknown String', () => { const newDate = dayjs().set('Unknown String', 1) expect(newDate.valueOf()) diff --git a/test/index.d.test.ts b/test/index.d.test.ts index d4a44844b..c97230d0d 100644 --- a/test/index.d.test.ts +++ b/test/index.d.test.ts @@ -71,3 +71,11 @@ dayjs().isSame(dayjs()) dayjs().isAfter(dayjs()) dayjs('2000-01-01').isLeapYear() + +dayjs().utc() + +dayjs().local() + +dayjs().isUTC() + +dayjs().utcOffset() diff --git a/test/manipulate.test.js b/test/manipulate.test.js index 6a85769ff..fb7551b26 100644 --- a/test/manipulate.test.js +++ b/test/manipulate.test.js @@ -20,6 +20,14 @@ describe('StartOf EndOf', () => { }) }) + it('StartOf EndOf Year ... in UTC mode', () => { + const testArr = ['year', 'month', 'day', 'date', 'week', 'hour', 'minute', 'second'] + testArr.forEach((d) => { + expect(dayjs().utc().startOf(d).valueOf()).toBe(moment().utc().startOf(d).valueOf()) + expect(dayjs().utc().endOf(d).valueOf()).toBe(moment().utc().endOf(d).valueOf()) + }) + }) + it('StartOf EndOf Other -> no change', () => { expect(dayjs().startOf('otherString').valueOf()).toBe(moment().startOf('otherString').valueOf()) expect(dayjs().endOf('otherString').valueOf()).toBe(moment().endOf('otherString').valueOf()) diff --git a/test/parse.test.js b/test/parse.test.js index 395d36882..6b186fc66 100644 --- a/test/parse.test.js +++ b/test/parse.test.js @@ -65,6 +65,20 @@ it('Number 0', () => { expect(dayjs(0).valueOf()).toBe(moment(0).valueOf()) }) +it('Recognizes the UTC flag in constructor options', () => { + const instance = dayjs('2018-09-06', { utc: true }) + expect(instance.$u).toBeTruthy() + expect(instance.hour()).toEqual(0) + expect(instance.minute()).toEqual(0) +}) + +it('Does not apply the UTC mode by default', () => { + const instance = dayjs('2018-09-06 19:34:28.657', {}) + expect(instance.$u).toBeFalsy() + expect(instance.hour()).toEqual(19) + expect(instance.minute()).toEqual(34) +}) + it('Clone not affect each other', () => { const base = dayjs(20170101) const year = base.year() diff --git a/test/utc/isUTC.test.js b/test/utc/isUTC.test.js new file mode 100644 index 000000000..88e0c20d5 --- /dev/null +++ b/test/utc/isUTC.test.js @@ -0,0 +1,20 @@ +import MockDate from 'mockdate' +import dayjs from '../../src' + +beforeEach(() => { + MockDate.set(new Date()) +}) + +afterEach(() => { + MockDate.reset() +}) + +it('Returns false by default', () => { + const instance = dayjs() + expect(instance.isUTC()).toBeFalsy() +}) + +it('Returns true for UTC instances', () => { + const instance = dayjs(undefined, { utc: true }) + expect(instance.isUTC()).toBeTruthy() +}) diff --git a/test/utc/local.test.js b/test/utc/local.test.js new file mode 100644 index 000000000..2dd865e64 --- /dev/null +++ b/test/utc/local.test.js @@ -0,0 +1,23 @@ +import MockDate from 'mockdate' +import dayjs from '../../src' + +beforeEach(() => { + MockDate.set(new Date()) +}) + +afterEach(() => { + MockDate.reset() +}) + +it('Returns a new instance', () => { + const instance = dayjs('2018-09-06T19:34:28.657Z') + const local = instance.local() + expect(local).not.toBe(instance) +}) + +it('Returns a local instance', () => { + const instance = dayjs('2018-09-06 19:34:28.657').local() + expect(instance.$u).toBeFalsy() + expect(instance.hour()).toEqual(19) + expect(instance.minute()).toEqual(34) +}) diff --git a/test/utc/utc.test.js b/test/utc/utc.test.js new file mode 100644 index 000000000..c6488635a --- /dev/null +++ b/test/utc/utc.test.js @@ -0,0 +1,23 @@ +import MockDate from 'mockdate' +import dayjs from '../../src' + +beforeEach(() => { + MockDate.set(new Date()) +}) + +afterEach(() => { + MockDate.reset() +}) + +it('Returns a new instance', () => { + const instance = dayjs('2018-09-06T19:34:28.657Z') + const utc = instance.utc() + expect(utc).not.toBe(instance) +}) + +it('Returns an UTC instance', () => { + const instance = dayjs('2018-09-06T19:34:28.657Z').utc() + expect(instance.$u).toBeTruthy() + expect(instance.hour()).toEqual(19) + expect(instance.minute()).toEqual(34) +}) diff --git a/test/utc/utcOffset.test.js b/test/utc/utcOffset.test.js new file mode 100644 index 000000000..46c540ef9 --- /dev/null +++ b/test/utc/utcOffset.test.js @@ -0,0 +1,21 @@ +import MockDate from 'mockdate' +import dayjs from '../../src' + +beforeEach(() => { + MockDate.set(new Date()) +}) + +afterEach(() => { + MockDate.reset() +}) + +it('Returns the UTC offset for local instances', () => { + const instance = dayjs('2018-09-06T19:34:28.657Z') + const date = instance.toDate() + expect(instance.utcOffset()).toEqual(date.getTimezoneOffset()) +}) + +it('Returns zero for UTC instances', () => { + const instance = dayjs(undefined, { utc: true }) + expect(instance.utcOffset()).toEqual(0) +})