From be80e318bc3bc2ee128c38ce11bf4f15124aa544 Mon Sep 17 00:00:00 2001 From: Valerii Sidorenko Date: Thu, 23 Nov 2023 13:46:59 +0100 Subject: [PATCH] fix: performance (#40) * fix: performance * fix: work with years from 0 to 100 * fix: do not patch dayjs object * test: add more tests * fix: rollback types changes --- src/dateTime/__tests__/diff.ts | 176 +++++++++++++++ src/dateTime/__tests__/from.ts | 26 +++ src/dateTime/dateTime.test.ts | 15 ++ src/dateTime/dateTime.ts | 394 +++++++++++++++++++++------------ src/dayjs.ts | 33 --- src/settings/settings.ts | 6 +- src/timeZone/timeZone.ts | 43 +++- src/typings/dateTime.ts | 2 +- src/utils/utils.ts | 9 +- tsconfig.json | 3 +- 10 files changed, 514 insertions(+), 193 deletions(-) create mode 100644 src/dateTime/__tests__/diff.ts create mode 100644 src/dateTime/__tests__/from.ts diff --git a/src/dateTime/__tests__/diff.ts b/src/dateTime/__tests__/diff.ts new file mode 100644 index 0000000..8e17f7c --- /dev/null +++ b/src/dateTime/__tests__/diff.ts @@ -0,0 +1,176 @@ +import type {DateTimeInput, DurationUnit} from '../../typings'; +import {dateTime} from '../dateTime'; + +function dstForYear(year: number) { + let end = dateTime({input: [year + 1]}); + let current = dateTime({input: [year]}); + let last; + + while (current < end) { + last = current; + current = current.add(24, 'hour'); + if (last.utcOffset() !== current.utcOffset()) { + end = current; + current = last; + break; + } + } + + while (current < end) { + last = current; + current = current.add(1, 'hour'); + if (last.utcOffset() !== current.utcOffset()) { + return { + dateTime: last, + diff: -(current.utcOffset() - last.utcOffset()) / 60, + }; + } + } + + return undefined; +} + +test('diff', () => { + expect(dateTime({input: 1000}).diff(0)).toBe(1000); // '1 second - 0 = 1000' + expect(dateTime({input: 1000}).diff(500)).toBe(500); // '1 second - 0.5 seconds = 500' + expect(dateTime({input: 0}).diff(1000)).toBe(-1000); // '0 - 1 second = -1000' + expect(dateTime({input: new Date(1000)}).diff(1000)).toBe(0); // '1 second - 1 second = 0' + const oneHourDate = new Date(2015, 5, 21); + const nowDate = new Date(Number(oneHourDate)); + oneHourDate.setHours(oneHourDate.getHours() + 1); + expect(dateTime({input: oneHourDate}).diff(nowDate)).toBe(60 * 60 * 1000); // '1 hour from now = 3600000' +}); + +test.each<[{date: DateTimeInput; unit: DurationUnit}, number]>([ + [{date: [2011], unit: 'year'}, -1], + [{date: [2010, 2], unit: 'month'}, -2], + [{date: [2010, 0, 7], unit: 'week'}, 0], + [{date: [2010, 0, 8], unit: 'week'}, -1], + [{date: [2010, 0, 21], unit: 'week'}, -2], + [{date: [2010, 0, 22], unit: 'week'}, -3], + [{date: [2010, 0, 4], unit: 'day'}, -3], + [{date: [2010, 0, 1, 0, 5], unit: 'minute'}, -5], + [{date: [2010, 0, 1, 0, 0, 6], unit: 'second'}, -6], +])('diff key after, (%j)', ({date, unit}, expected) => { + expect(dateTime({input: [2010]}).diff(date, unit)).toBe(expected); +}); + +test.each<[{date: DateTimeInput; unit: DurationUnit}, number]>([ + [{date: [2011], unit: 'year'}, 1], + [{date: [2010, 2], unit: 'month'}, 2], + [{date: [2010, 0, 7], unit: 'week'}, 0], + [{date: [2010, 0, 8], unit: 'week'}, 1], + [{date: [2010, 0, 21], unit: 'week'}, 2], + [{date: [2010, 0, 22], unit: 'week'}, 3], + [{date: [2010, 0, 4], unit: 'day'}, 3], + [{date: [2010, 0, 1, 0, 5], unit: 'minute'}, 5], + [{date: [2010, 0, 1, 0, 0, 6], unit: 'second'}, 6], +])('diff key before, (%j)', ({date, unit}, expected) => { + expect(dateTime({input: date}).diff([2010], unit)).toBe(expected); +}); + +test('diff month', () => { + expect(dateTime({input: [2011, 0, 31]}).diff([2011, 2, 1], 'months')).toBe(-1); +}); + +test('end of month diff', () => { + expect(dateTime({input: '2016-02-29'}).diff('2016-01-30', 'months')).toBe(1); // 'Feb 29 to Jan 30 should be 1 month' + expect(dateTime({input: '2016-02-29'}).diff('2016-01-31', 'months')).toBe(1); // 'Feb 29 to Jan 31 should be 1 month' + expect(dateTime({input: '2016-05-31'}).add(1, 'month').diff('2016-05-31', 'month')).toBe(1); // '(May 31 plus 1 month) to May 31 should be 1 month diff', +}); + +test('end of month diff with time behind', () => { + expect(dateTime({input: '2017-03-31'}).diff('2017-02-28', 'months')).toBe(1); // 'Feb 28 to March 31 should be 1 month', + expect(dateTime({input: '2017-02-28'}).diff('2017-03-31', 'months')).toBe(-1); //'Feb 28 to March 31 should be 1 month', +}); + +test('diff across DST', () => { + const dst = dstForYear(2012); + if (!dst) { + expect(42).toBe(42); // 'at least one assertion' + return; + } + + let a, b; + + a = dst.dateTime; + b = a.utc().add(12, 'hours').local(); + expect(b.diff(a, 'milliseconds', true)).toBe(12 * 60 * 60 * 1000); // 'ms diff across DST' + expect(b.diff(a, 'seconds', true)).toBe(12 * 60 * 60); // 'second diff across DST' + expect(b.diff(a, 'minutes', true)).toBe(12 * 60); // 'minute diff across DST' + expect(b.diff(a, 'hours', true)).toBe(12); // 'hour diff across DST' + expect(b.diff(a, 'days', true)).toBe((12 - dst.diff) / 24); // 'day diff across DST' + // due to floating point math errors, these tests just need to be accurate within 0.00000001 + expect(Math.abs(b.diff(a, 'weeks', true) - (12 - dst.diff) / 24 / 7) < 0.00000001).toBe(true); // 'week diff across DST' + expect(0.95 / (2 * 31) < b.diff(a, 'months', true)).toBe(true); // 'month diff across DST, lower bound' + expect(b.diff(a, 'month', true) < 1.05 / (2 * 28)).toBe(true); // 'month diff across DST, upper bound'); + expect(0.95 / (2 * 31 * 12) < b.diff(a, 'years', true)).toBe(true); // 'year diff across DST, lower bound' + expect(b.diff(a, 'year', true) < 1.05 / (2 * 28 * 12)).toBe(true); // 'year diff across DST, upper bound' + + a = dst.dateTime; + b = a + .utc() + .add(12 + dst.diff, 'hours') + .local(); + + expect(b.diff(a, 'milliseconds', true)).toBe((12 + dst.diff) * 60 * 60 * 1000); // 'ms diff across DST' + expect(b.diff(a, 'seconds', true)).toBe((12 + dst.diff) * 60 * 60); // 'second diff across DST'); + expect(b.diff(a, 'minutes', true)).toBe((12 + dst.diff) * 60); // 'minute diff across DST' + expect(b.diff(a, 'hours', true)).toBe(12 + dst.diff); // 'hour diff across DST' + expect(b.diff(a, 'days', true)).toBe(12 / 24); // 'day diff across DST' + // due to floating point math errors, these tests just need to be accurate within 0.00000001 + expect(Math.abs(b.diff(a, 'weeks', true) - 12 / 24 / 7) < 0.00000001).toBe(true); // 'week diff across DST' + expect(0.95 / (2 * 31) < b.diff(a, 'months', true)).toBe(true); // 'month diff across DST, lower bound' + expect(b.diff(a, 'month', true) < 1.05 / (2 * 28)).toBe(true); // 'month diff across DST, upper bound' + expect(0.95 / (2 * 31 * 12) < b.diff(a, 'years', true)).toBe(true); // 'year diff across DST, lower bound' + expect(b.diff(a, 'year', true) < 1.05 / (2 * 28 * 12)).toBe(true); // 'year diff across DST, upper bound' +}); + +test.each<[{date: DateTimeInput; unit: DurationUnit}, number]>([ + [{date: [2011], unit: 'month'}, 12], + [{date: [2010, 0, 2], unit: 'hour'}, 24], + [{date: [2010, 0, 1, 2], unit: 'minute'}, 120], + [{date: [2010, 0, 1, 0, 4], unit: 'second'}, 240], +])('diff overflow, (%j)', ({date, unit}, expected) => { + expect(dateTime({input: date}).diff([2010], unit)).toBe(expected); +}); + +test('diff between utc and local (not Russia)', () => { + if (dateTime({input: [2012]}).utcOffset() === dateTime({input: [2011]}).utcOffset()) { + // Russia's utc offset on 1st of Jan 2012 vs 2011 is different + expect( + dateTime({input: [2012]}) + .utc() + .diff([2011], 'years'), + ).toBe(1); + } +}); + +test.each<[{date1: DateTimeInput; date2: DateTimeInput; unit: DurationUnit}, number]>([ + [{date1: [2010, 2, 2], date2: [2010, 0, 2], unit: 'months'}, 2], + [{date1: [2010, 0, 4], date2: [2010], unit: 'days'}, 3], + [{date1: [2010, 0, 22], date2: [2010], unit: 'weeks'}, 3], + [{date1: [2010, 0, 1, 4], date2: [2010], unit: 'hours'}, 4], + [{date1: [2010, 0, 1, 0, 5], date2: [2010], unit: 'minutes'}, 5], + [{date1: [2010, 0, 1, 0, 0, 6], date2: [2010], unit: 'seconds'}, 6], +])('diff between utc and local', ({date1, date2, unit}, expected) => { + expect(dateTime({input: date1}).utc().diff(date2, unit)).toBe(expected); +}); + +test.each<[{date1: DateTimeInput; date2: DateTimeInput; unit: DurationUnit}, number]>([ + [{date1: [2010, 0, 1, 23], date2: [2010], unit: 'day'}, 0], + [{date1: [2010, 0, 1, 23, 59], date2: [2010], unit: 'day'}, 0], + [{date1: [2010, 0, 1, 24], date2: [2010], unit: 'day'}, 1], + [{date1: [2010, 0, 2], date2: [2011, 0, 1], unit: 'year'}, 0], + [{date1: [2011, 0, 1], date2: [2010, 0, 2], unit: 'year'}, 0], + [{date1: [2010, 0, 2], date2: [2011, 0, 2], unit: 'year'}, -1], + [{date1: [2011, 0, 2], date2: [2010, 0, 2], unit: 'year'}, 1], +])('diff floored, (%j)', ({date1, date2, unit}, expected) => { + expect(dateTime({input: date1}).diff(date2, unit)).toBe(expected); +}); + +test('year diff should include date of month', () => { + expect( + dateTime({input: [2012, 1, 19]}).diff(dateTime({input: [2002, 1, 20]}), 'years', true) < 10, + ).toBe(true); +}); diff --git a/src/dateTime/__tests__/from.ts b/src/dateTime/__tests__/from.ts new file mode 100644 index 0000000..04590a5 --- /dev/null +++ b/src/dateTime/__tests__/from.ts @@ -0,0 +1,26 @@ +import type {DurationUnit} from '../../typings'; +import {dateTime} from '../dateTime'; + +test.each<[{method: 'add' | 'subtract'; amount: number; unit: DurationUnit}, string]>([ + [{method: 'add', amount: 5, unit: 'seconds'}, 'a few seconds ago'], + [{method: 'add', amount: 1, unit: 'minute'}, 'a minute ago'], + [{method: 'add', amount: 5, unit: 'minutes'}, '5 minutes ago'], + [{method: 'subtract', amount: 5, unit: 'seconds'}, 'in a few seconds'], + [{method: 'subtract', amount: 1, unit: 'minute'}, 'in a minute'], + [{method: 'subtract', amount: 5, unit: 'minutes'}, 'in 5 minutes'], +])('from (%j)', ({method, amount, unit}, expected) => { + const start = dateTime({lang: 'en'}); + expect(start.from(start[method](amount, unit))).toBe(expected); +}); + +test.each<[{method: 'add' | 'subtract'; amount: number; unit: DurationUnit}, string]>([ + [{method: 'add', amount: 5, unit: 'seconds'}, 'a few seconds'], + [{method: 'add', amount: 1, unit: 'minute'}, 'a minute'], + [{method: 'add', amount: 5, unit: 'minutes'}, '5 minutes'], + [{method: 'subtract', amount: 5, unit: 'seconds'}, 'a few seconds'], + [{method: 'subtract', amount: 1, unit: 'minute'}, 'a minute'], + [{method: 'subtract', amount: 5, unit: 'minutes'}, '5 minutes'], +])('from with absolute duration(%j)', ({method, amount, unit}, expected) => { + const start = dateTime({lang: 'en'}); + expect(start.from(start[method](amount, unit), true)).toBe(expected); +}); diff --git a/src/dateTime/dateTime.test.ts b/src/dateTime/dateTime.test.ts index ae9a281..94d3867 100644 --- a/src/dateTime/dateTime.test.ts +++ b/src/dateTime/dateTime.test.ts @@ -212,5 +212,20 @@ describe('DateTime', () => { expect(dateTime({input: '20130531', format: 'YYYYMMDD'}).month(3).month()).toBe(3); }); + + it('should work with years >= 0 and < 100 ', () => { + const date = dateTime({input: '0001-01-12T00:00:00Z', timeZone: 'Europe/Amsterdam'}); + expect(date.toISOString()).toBe('0001-01-12T00:00:00.000Z'); + expect(date.startOf('s').toISOString()).toBe('0001-01-12T00:00:00.000Z'); + expect(date.startOf('s').valueOf()).toBe(date.valueOf()); + expect(date.set({year: 2, month: 1, date: 20}).toISOString()).toBe( + '0002-02-20T00:00:00.000Z', + ); + expect(date.add(1, 'year').toISOString()).toBe('0002-01-12T00:00:00.000Z'); + expect(date.subtract(1, 'year').toISOString()).toBe('0000-01-12T00:00:00.000Z'); + + expect(date.isSame(date)).toBe(true); + expect(date.valueOf()).toBe(date.startOf('ms').valueOf()); + }); }); }); diff --git a/src/dateTime/dateTime.ts b/src/dateTime/dateTime.ts index 1d636e0..1e47bed 100644 --- a/src/dateTime/dateTime.ts +++ b/src/dateTime/dateTime.ts @@ -1,7 +1,7 @@ import {STRICT, UtcTimeZone} from '../constants'; import dayjs from '../dayjs'; import {settings} from '../settings'; -import {fixOffset, normalizeTimeZone, timeZoneOffset} from '../timeZone'; +import {fixOffset, guessUserTimeZone, normalizeTimeZone, timeZoneOffset} from '../timeZone'; import type { AllUnit, DateTime, @@ -15,6 +15,7 @@ import type { import { daysInMonth, getDuration, + normalizeComponent, normalizeDateComponents, objToTS, offsetFromString, @@ -37,45 +38,20 @@ class DateTimeImpl implements DateTime { private _locale: string; private _date: dayjs.Dayjs; - constructor( - opt: { - input?: DateTimeInput; - format?: FormatInput; - timeZone?: TimeZone; - utcOffset?: number; - locale?: string; - } = {}, - ) { + constructor(opt: { + ts: number; + timeZone: TimeZone; + offset: number; + locale: string; + date: dayjs.Dayjs; + }) { this[IS_DATE_TIME] = true; - let input = opt.input; - if (DateTimeImpl.isDateTime(input)) { - input = input.valueOf(); - } - const locale = opt.locale || settings.getLocale(); - const localDate = opt.format - ? // DateTimeInput !== dayjs.ConfigType; - // Array !== [number?, number?, number?, number?, number?, number?, number?] - // @ts-expect-error - dayjs(input, opt.format, locale, STRICT) - : // DateTimeInput !== dayjs.ConfigType; - // Array !== [number?, number?, number?, number?, number?, number?, number?] - // @ts-expect-error - dayjs(input, undefined, locale); - - this._timestamp = localDate.valueOf(); - this._locale = locale; - if (typeof opt.utcOffset === 'number') { - this._timeZone = UtcTimeZone; - this._offset = opt.utcOffset; - this._date = localDate.utc().utcOffset(this._offset).locale(locale); - // @ts-expect-error set timezone to utc date, it will be shown with `format('z')`. - this._date.$x.$timezone = this._timeZone; - } else { - this._timeZone = normalizeTimeZone(opt.timeZone, settings.getDefaultTimeZone()); - this._offset = timeZoneOffset(this._timeZone, this._timestamp); - this._date = localDate.locale(locale).tz(this._timeZone); - } + this._timestamp = opt.ts; + this._locale = opt.locale; + this._timeZone = opt.timeZone; + this._offset = opt.offset; + this._date = opt.date; } format(formatInput?: FormatInput) { @@ -113,7 +89,7 @@ class DateTimeImpl implements DateTime { ts -= (newOffset - this._offset) * 60 * 1000; } return createDateTime({ - input: ts, + ts, timeZone: UtcTimeZone, offset: newOffset, locale: this._locale, @@ -127,56 +103,120 @@ class DateTimeImpl implements DateTime { timeZone(timeZone: string, keepLocalTime?: boolean | undefined): DateTime; timeZone(timeZone?: string, keepLocalTime?: boolean | undefined): DateTime | string { if (timeZone === undefined) { - return this._timeZone; + return this._timeZone === 'system' ? guessUserTimeZone() : this._timeZone; } - let ts = this.valueOf(); const zone = normalizeTimeZone(timeZone, settings.getDefaultTimeZone()); + let ts = this.valueOf(); + let offset = timeZoneOffset(zone, ts); if (keepLocalTime) { - const offset = timeZoneOffset(zone, ts); ts += this._offset * 60 * 1000; - ts = fixOffset(ts, offset, zone)[0]; + [ts, offset] = fixOffset(ts, offset, zone); } - return createDateTime({input: ts, timeZone: zone, locale: this._locale}); + return createDateTime({ts, timeZone: zone, offset, locale: this._locale}); } add(amount: DateTimeInput, unit?: DurationUnit): DateTime { - return addSubtract(this, amount, unit, 1); + return this.addSubtract(amount, unit, 1); } subtract(amount: DateTimeInput, unit?: DurationUnit): DateTime { - return addSubtract(this, amount, unit, -1); + return this.addSubtract(amount, unit, -1); } - startOf(unitOfTime: StartOfUnit): DateTime { - // type of startOf is ((unit: QuarterUnit) => DateJs) | ((unit: BaseUnit) => DateJs). - // It cannot get unit of type QuarterUnit | BaseUnit - // @ts-expect-error - const ts = this._date.startOf(unitOfTime).valueOf(); - return createDateTime({ - input: ts, - timeZone: this._timeZone, - offset: this._offset, - locale: this._locale, - }); + startOf(unitOfTime: StartOfUnit | 'weekNumber' | 'isoWeekNumber' | 'isoWeekday') { + if (!this.isValid()) { + return this; + } + + const dateComponents: Partial< + Record<'year' | 'month' | 'date' | 'hour' | 'minute' | 'second' | 'millisecond', number> + > = {}; + const unit = normalizeComponent(unitOfTime); + /* eslint-disable no-fallthrough */ + switch (unit) { + case 'year': + dateComponents.month = 0; + case 'quarter': + dateComponents.month = this.month() - (this.month() % 3); + case 'month': + case 'weekNumber': + case 'isoWeekNumber': + if (unit === 'weekNumber') { + dateComponents.date = this.date() - this.weekday(); + } else if (unit === 'isoWeekNumber') { + dateComponents.date = this.date() - (this.isoWeekday() - 1); + } else { + dateComponents.date = 1; + } + case 'day': + case 'date': + case 'isoWeekday': + dateComponents.hour = 0; + case 'hour': + dateComponents.minute = 0; + case 'minute': + dateComponents.second = 0; + case 'second': { + dateComponents.millisecond = 0; + } + } + /* eslint-enable no-fallthrough */ + + return this.set(dateComponents); } - endOf(unitOfTime: StartOfUnit): DateTime { - // type of endOf is ((unit: QuarterUnit) => DateJs) | ((unit: BaseUnit) => DateJs). - // It cannot get unit of type QuarterUnit | BaseUnit - // @ts-expect-error - const ts = this._date.endOf(unitOfTime).valueOf(); - return createDateTime({ - input: ts, - timeZone: this._timeZone, - offset: this._offset, - locale: this._locale, - }); + endOf(unitOfTime: StartOfUnit | 'weekNumber' | 'isoWeekNumber' | 'isoWeekday'): DateTime { + if (!this.isValid()) { + return this; + } + + const dateComponents: Partial< + Record<'year' | 'month' | 'date' | 'hour' | 'minute' | 'second' | 'millisecond', number> + > = {}; + const unit = normalizeComponent(unitOfTime); + /* eslint-disable no-fallthrough */ + switch (unit) { + case 'year': + case 'quarter': + if (unit === 'quarter') { + dateComponents.month = this.month() - (this.month() % 3) + 2; + } else { + dateComponents.month = 11; + } + case 'month': + case 'weekNumber': + case 'isoWeekNumber': + if (unit === 'weekNumber') { + dateComponents.date = this.date() - this.weekday() + 6; + } else if (unit === 'isoWeekNumber') { + dateComponents.date = this.date() - (this.isoWeekday() - 1) + 6; + } else { + dateComponents.date = daysInMonth( + this.year(), + dateComponents.month ?? this.month(), + ); + } + case 'day': + case 'date': + case 'isoWeekday': + dateComponents.hour = 23; + case 'hour': + dateComponents.minute = 59; + case 'minute': + dateComponents.second = 59; + case 'second': { + dateComponents.millisecond = 999; + } + } + /* eslint-enable no-fallthrough */ + + return this.set(dateComponents); } local(keepLocalTime?: boolean): DateTime { - return this.timeZone('default', keepLocalTime); + return this.timeZone('system', keepLocalTime); } valueOf(): number { @@ -184,27 +224,31 @@ class DateTimeImpl implements DateTime { } isSame(input?: DateTimeInput, granularity?: DurationUnit): boolean { - const value = DateTimeImpl.isDateTime(input) ? input.valueOf() : input; - // DateTimeInput !== dayjs.ConfigType; - // Array !== [number?, number?, number?, number?, number?, number?, number?] - // @ts-expect-error - return this._date.isSame(value, granularity); + const ts = getTimestamp(input); + if (!this.isValid() || isNaN(ts)) { + return false; + } + return !this.isBefore(ts, granularity) && !this.isAfter(ts, granularity); } - isBefore(input?: DateTimeInput): boolean { - const value = DateTimeImpl.isDateTime(input) ? input.valueOf() : input; - // DateTimeInput !== dayjs.ConfigType; - // Array !== [number?, number?, number?, number?, number?, number?, number?] - // @ts-expect-error - return this._date.isBefore(value); + isBefore(input?: DateTimeInput, granularity?: DurationUnit): boolean { + const ts = getTimestamp(input); + if (!this.isValid() || isNaN(ts)) { + return false; + } + const unit = normalizeComponent(granularity ?? 'millisecond'); + const localTs = unit === 'millisecond' ? this.valueOf() : this.endOf(unit).valueOf(); + return localTs < ts; } - isAfter(input?: DateTimeInput): boolean { - const value = DateTimeImpl.isDateTime(input) ? input.valueOf() : input; - // DateTimeInput !== dayjs.ConfigType; - // Array !== [number?, number?, number?, number?, number?, number?, number?] - // @ts-expect-error - return this._date.isBefore(value); + isAfter(input?: DateTimeInput, granularity?: DurationUnit): boolean { + const ts = getTimestamp(input); + if (!this.isValid() || isNaN(ts)) { + return false; + } + const unit = normalizeComponent(granularity ?? 'millisecond'); + const localTs = unit === 'millisecond' ? this.valueOf() : this.startOf(unit).valueOf(); + return localTs > ts; } isValid(): boolean { @@ -214,7 +258,7 @@ class DateTimeImpl implements DateTime { diff( amount: DateTimeInput, unit?: DurationUnit | undefined, - truncate?: boolean | undefined, + asFloat?: boolean | undefined, ): number { const value = DateTimeImpl.isDateTime(amount) ? amount.valueOf() : amount; // value: @@ -223,7 +267,7 @@ class DateTimeImpl implements DateTime { // unit: // the same problem as for startOf // @ts-expect-error - return this._date.diff(value, unit, truncate); + return this._date.diff(value, unit, asFloat); } fromNow(withoutSuffix?: boolean | undefined): string { return this._date.fromNow(withoutSuffix); @@ -242,10 +286,10 @@ class DateTimeImpl implements DateTime { return this._locale; } return createDateTime({ - input: this.valueOf(), + ts: this.valueOf(), timeZone: this._timeZone, offset: this._offset, - locale: locale, + locale: dayjs.locale(locale, undefined, true), }); } toDate(): Date { @@ -255,11 +299,7 @@ class DateTimeImpl implements DateTime { return Math.floor(this.valueOf() / 1000); } utc(keepLocalTime?: boolean | undefined): DateTime { - let ts = this.valueOf(); - if (keepLocalTime) { - ts += this._offset * 60 * 1000; - } - return new DateTimeImpl({input: ts, timeZone: UtcTimeZone}); + return this.timeZone(UtcTimeZone, keepLocalTime); } daysInMonth(): number { return this._date.daysInMonth(); @@ -288,7 +328,7 @@ class DateTimeImpl implements DateTime { let mixed; if (settingWeekStuff) { - let date = dayjs.utc(objToTS(dateComponents)); + let date = dayjs.utc(objToTS({...dateComponents, ...newComponents})); const toDayjsUnit = { weekNumber: 'week', day: 'day', @@ -311,16 +351,17 @@ class DateTimeImpl implements DateTime { } let ts = objToTS(mixed); + let offset = this._offset; if (this._timeZone === UtcTimeZone) { - ts -= this._offset * 60 * 1000; + ts -= offset * 60 * 1000; } else { - ts = fixOffset(ts, this._offset, this._timeZone)[0]; + [ts, offset] = fixOffset(ts, offset, this._timeZone); } return createDateTime({ - input: ts, + ts, timeZone: this._timeZone, - offset: this._offset, + offset, locale: this._locale, }); } @@ -424,46 +465,59 @@ class DateTimeImpl implements DateTime { } return this._date.isoWeek(); } + weekday(): number { + // @ts-expect-error get locale object + const weekStart = this._date.$locale().weekStart || 0; + const day = this.day(); + const weekday = (day < weekStart ? day + 7 : day) - weekStart; + return weekday; + } toString(): string { return this._date.toString(); } -} + private addSubtract(amount: DateTimeInput, unit: DurationUnit | undefined, sign: 1 | -1) { + if (!this.isValid()) { + return this; + } -function addSubtract( - instance: DateTime, - amount: DateTimeInput, - unit: DurationUnit | undefined, - sign: 1 | -1, -) { - const duration = getDuration(amount, unit); - const dateComponents = tsToObject(instance.valueOf(), instance.utcOffset()); - - const monthsInput = absRound(duration.months); - const daysInput = absRound(duration.days); - - let ts = instance.valueOf(); - - if (monthsInput || daysInput) { - const month = dateComponents.month + sign * monthsInput; - const date = - Math.min(dateComponents.date, daysInMonth(dateComponents.year, month)) + - sign * daysInput; - ts = objToTS({...dateComponents, month, date}); - if (instance.timeZone() === UtcTimeZone) { - ts -= instance.utcOffset() * 60 * 1000; - } else { - ts = fixOffset(ts, instance.utcOffset(), instance.timeZone())[0]; + const timeZone = this._timeZone; + let ts = this.valueOf(); + let offset = this._offset; + + const duration = getDuration(amount, unit); + const dateComponents = tsToObject(ts, offset); + + const monthsInput = absRound(duration.months); + const daysInput = absRound(duration.days); + + if (monthsInput || daysInput) { + const month = dateComponents.month + sign * monthsInput; + const date = + Math.min(dateComponents.date, daysInMonth(dateComponents.year, month)) + + sign * daysInput; + ts = objToTS({...dateComponents, month, date}); + if (timeZone === UtcTimeZone) { + ts -= offset * 60 * 1000; + } else { + [ts, offset] = fixOffset(ts, offset, timeZone); + } } - } - ts += sign * duration.milliseconds; - return createDateTime({ - input: ts, - timeZone: instance.timeZone(), - offset: instance.utcOffset(), - locale: instance.locale(), - }); + if (duration.milliseconds) { + ts += sign * duration.milliseconds; + if (timeZone !== UtcTimeZone) { + offset = timeZoneOffset(timeZone, ts); + } + } + + return createDateTime({ + ts, + timeZone, + offset, + locale: this._locale, + }); + } } function absRound(v: number) { @@ -472,18 +526,58 @@ function absRound(v: number) { } function createDateTime({ - input, + ts, timeZone, offset, locale, }: { - input: number; - timeZone?: string; - offset?: number; - locale: string | undefined; + ts: number; + timeZone: string; + offset: number; + locale: string; }): DateTime { - const utcOffset = timeZone === UtcTimeZone && offset !== 0 ? offset : undefined; - return new DateTimeImpl({input, timeZone, utcOffset, locale}); + let date: dayjs.Dayjs; + if (timeZone === 'system') { + date = dayjs(ts, {locale}); + } else { + let localOffset = timeZoneOffset('system', ts); + let newTs = ts; + if (offset !== 0 && localOffset !== offset) { + newTs += offset * 60 * 1000; + [newTs, localOffset] = fixOffset(newTs, localOffset, 'system'); + } + date = dayjs(newTs, { + locale, + utc: offset === 0, + // @ts-expect-error private fields used by utc and timezone plugins + $offset: offset ? offset : undefined, + x: {$timezone: timeZone, $localOffset: -localOffset}, + }); + } + + return new DateTimeImpl({ts, timeZone, offset, locale, date}); +} + +function getTimestamp(input: DateTimeInput, format?: string, lang?: string) { + const locale = dayjs.locale(lang || settings.getLocale(), undefined, true); + + let ts: number; + if (DateTimeImpl.isDateTime(input) || typeof input === 'number' || input instanceof Date) { + ts = Number(input); + } else { + const localDate = format + ? // DateTimeInput !== dayjs.ConfigType; + // Array !== [number?, number?, number?, number?, number?, number?, number?] + // @ts-expect-error + dayjs(input, format, locale, STRICT) + : // DateTimeInput !== dayjs.ConfigType; + // Array !== [number?, number?, number?, number?, number?, number?, number?] + // @ts-expect-error + dayjs(input, undefined, locale); + + ts = localDate.valueOf(); + } + return ts; } /** @@ -502,15 +596,27 @@ export const isDateTime = (value: unknown): value is DateTime => { * @param {string=} opt.timeZone - specified {@link https://dayjs.gitee.io/docs/en/timezone/timezone time zone}. * @param {string=} opt.lang - specified locale. */ -export const dateTime = (opt?: { +export function dateTime(opt?: { input?: DateTimeInput; format?: FormatInput; timeZone?: TimeZone; lang?: string; -}): DateTime => { +}): DateTime { const {input, format, timeZone, lang} = opt || {}; - const date = new DateTimeImpl({input, format, timeZone, locale: lang}); + const timeZoneOrDefault = normalizeTimeZone(timeZone, settings.getDefaultTimeZone()); + const locale = dayjs.locale(lang || settings.getLocale(), undefined, true); + + const ts = getTimestamp(input, format, lang); + + const offset = timeZoneOffset(timeZoneOrDefault, ts); + + const date = createDateTime({ + ts, + timeZone: timeZoneOrDefault, + offset, + locale, + }); return date; -}; +} diff --git a/src/dayjs.ts b/src/dayjs.ts index 39f7f99..959d503 100644 --- a/src/dayjs.ts +++ b/src/dayjs.ts @@ -12,8 +12,6 @@ import updateLocale from 'dayjs/plugin/updateLocale'; import utc from 'dayjs/plugin/utc'; import weekOfYear from 'dayjs/plugin/weekOfYear'; -import {fixOffset, timeZoneOffset} from './timeZone'; - dayjs.extend(arraySupport); dayjs.extend(customParseFormat); dayjs.extend(weekOfYear); @@ -31,37 +29,6 @@ dayjs.extend(updateLocale); // but not vice versa, therefore it should come last dayjs.extend(objectSupport); -dayjs.extend((_, Dayjs, d) => { - const proto = Dayjs.prototype; - - // override `tz` method from timezone plugin - // dayjs incorrectly transform dates to timezone if user local timezone use DST - // and date near a switching time. For example, if local timezone `Europe/Amsterdam` then dayjs gives incorrect result: - // dayjs('2023-10-29T00:00:00Z').valueOf() !== dayjs('2023-10-29T00:00:00Z').tz('Europe/Moscow').valueOf() - // and - // dayjs('2023-10-29T00:00:00Z').tz('Europe/Moscow').format() === '2023-10-29T03:00:00+04:00' - // but should be '2023-10-29T03:00:00+03:00' - proto.tz = function (timeZone: string, keepLocalTime = false) { - let ts = this.valueOf(); - let offset = timeZoneOffset(timeZone, ts); - - if (keepLocalTime) { - const oldOffset = this.utcOffset(); - ts += oldOffset * 60 * 1000; - [ts, offset] = fixOffset(ts, offset, timeZone); - } - - const target = new Date(ts).toLocaleString('en-US', {timeZone}); - // use private members of Dayjs object - // @ts-expect-error - const ins = d(target, {locale: this.$L}).$set('millisecond', ts % 1000); - ins.$offset = offset; - ins.$u = offset === 0; - ins.$x.$timezone = timeZone; - return ins; - }; -}); - export default dayjs; export type {ConfigTypeMap, ConfigType} from 'dayjs'; diff --git a/src/settings/settings.ts b/src/settings/settings.ts index f592ca7..d527b45 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -1,7 +1,7 @@ import cloneDeep from 'lodash/cloneDeep'; import dayjs from '../dayjs'; -import {guessUserTimeZone, normalizeTimeZone} from '../timeZone'; +import {normalizeTimeZone} from '../timeZone'; import type {UpdateLocaleConfig} from './types'; @@ -9,7 +9,7 @@ class Settings { // 'en' - preloaded locale in dayjs private loadedLocales = new Set(['en']); private defaultLocale = 'en'; - private defaultTimeZone = guessUserTimeZone(); + private defaultTimeZone = 'system'; constructor() { this.updateLocale({ @@ -68,7 +68,7 @@ class Settings { } setDefaultTimeZone(zone: 'system' | (string & {})) { - this.defaultTimeZone = normalizeTimeZone(zone, guessUserTimeZone()); + this.defaultTimeZone = normalizeTimeZone(zone, 'system'); } getDefaultTimeZone() { diff --git a/src/timeZone/timeZone.ts b/src/timeZone/timeZone.ts index af5f8e2..f529fe6 100644 --- a/src/timeZone/timeZone.ts +++ b/src/timeZone/timeZone.ts @@ -14,15 +14,22 @@ export const guessUserTimeZone = () => dayjs.tz.guess(); // @ts-expect-error https://github.com/microsoft/TypeScript/issues/49231 export const getTimeZonesList = (): string[] => Intl.supportedValuesOf?.('timeZone') || []; +const validTimeZones: Record = {}; export function isValidTimeZone(zone: string) { if (!zone) { return false; } + if (Object.prototype.hasOwnProperty.call(validTimeZones, zone)) { + return validTimeZones[zone]; + } + try { new Intl.DateTimeFormat('en-US', {timeZone: zone}).format(); + validTimeZones[zone] = true; return true; } catch { + validTimeZones[zone] = false; return false; } } @@ -32,7 +39,7 @@ function makeDateTimeFormat(zone: TimeZone) { if (!dateTimeFormatCache[zone]) { dateTimeFormatCache[zone] = new Intl.DateTimeFormat('en-US', { hour12: false, - timeZone: zone, + timeZone: zone === 'system' ? undefined : zone, year: 'numeric', month: '2-digit', day: '2-digit', @@ -56,19 +63,37 @@ const dateFields = [ ] satisfies Intl.DateTimeFormatPartTypes[]; type DateField = (typeof dateFields)[number]; type DateParts = Record, number> & {era: string}; +function isDateField(v: string): v is DateField { + return dateFields.includes(v as DateField); +} export function timeZoneOffset(zone: TimeZone, ts: number) { const date = new Date(ts); - if (isNaN(date.valueOf()) || !isValidTimeZone(zone)) { + if (isNaN(date.valueOf()) || (zone !== 'system' && !isValidTimeZone(zone))) { return NaN; } + if (zone === 'system') { + return -date.getTimezoneOffset(); + } + const dtf = makeDateTimeFormat(zone); - const parts = Object.fromEntries( - dtf - .formatToParts(date) - .filter(({type}) => dateFields.includes(type as DateField)) - .map(({type, value}) => [type, type === 'era' ? value : parseInt(value, 10)]), - ) as DateParts; + const formatted = dtf.formatToParts(date); + const parts: DateParts = { + year: 1, + month: 1, + day: 1, + hour: 0, + minute: 0, + second: 0, + era: 'AD', + }; + for (const {type, value} of formatted) { + if (type === 'era') { + parts.era = value; + } else if (isDateField(type)) { + parts[type] = parseInt(value, 10); + } + } // Date.UTC(year), year: 0 — is 1 BC, -1 — is 2 BC, e.t.c const year = parts.era === 'BC' ? -Math.abs(parts.year) + 1 : parts.year; @@ -103,7 +128,7 @@ export function normalizeTimeZone(input: string | undefined, defaultZone: string } if (lowered === 'system') { - return guessUserTimeZone(); + return 'system'; } if (lowered === 'default') { diff --git a/src/typings/dateTime.ts b/src/typings/dateTime.ts index dab272e..9f1c515 100644 --- a/src/typings/dateTime.ts +++ b/src/typings/dateTime.ts @@ -72,7 +72,7 @@ export interface DateTime extends Object { subtract(amount: DurationInput, unit?: DurationUnit): DateTime; set(unit: AllUnit, amount: number): DateTime; set(amount: SetObject): DateTime; - diff(amount: DateTimeInput, unit?: DurationUnit, truncate?: boolean): number; + diff(amount: DateTimeInput, unit?: DurationUnit, asFloat?: boolean): number; format(formatInput?: FormatInput): string; fromNow(withoutSuffix?: boolean): string; from(formaInput: DateTimeInput, withoutSuffix?: boolean): string; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 9ab50d8..248f6ce 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -56,7 +56,7 @@ export function objToTS(obj: Record, number> // set the month and day again, this is necessary because year 2000 is a leap year, but year 100 is not // so if obj.year is in 99, but obj.day makes it roll over into year 100, // the calculations done by Date.UTC are using year 2000 - which is incorrect - d.setUTCFullYear(obj.year, obj.month - 1, obj.date); + d.setUTCFullYear(obj.year, obj.month, obj.date); return d.valueOf(); } @@ -105,18 +105,23 @@ const normalizedUnits = { d: 'day', day: 'day', days: 'day', + weeknumber: 'weekNumber', w: 'weekNumber', week: 'weekNumber', weeks: 'weekNumber', + isoweeknumber: 'isoWeekNumber', W: 'isoWeekNumber', isoweek: 'isoWeekNumber', isoweeks: 'isoWeekNumber', E: 'isoWeekday', isoweekday: 'isoWeekday', isoweekdays: 'isoWeekday', + weekday: 'day', + weekdays: 'day', + e: 'day', } as const; -function normalizeComponent(component: string) { +export function normalizeComponent(component: string) { const unit = ['d', 'D', 'm', 'M', 'w', 'W', 'E', 'Q'].includes(component) ? component : component.toLowerCase(); diff --git a/tsconfig.json b/tsconfig.json index 16a5b70..45a5b63 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,8 @@ "allowJs": false, "module": "ESNext", "moduleResolution": "Node10", - "verbatimModuleSyntax": true + "verbatimModuleSyntax": true, + "noFallthroughCasesInSwitch": false }, "include": ["**/*"], "exclude": ["build"]