From f6d29044042e412dd7532e280360c0fc4e43eff7 Mon Sep 17 00:00:00 2001 From: Valeriy Sidorenko Date: Thu, 16 Nov 2023 18:12:20 +0100 Subject: [PATCH 1/5] fix: performance --- src/dateTime/dateTime.ts | 170 ++++++++++++++++++++++----------------- src/dayjs.ts | 62 +++++++++++--- src/timeZone/timeZone.ts | 39 +++++++-- src/utils/utils.ts | 3 + 4 files changed, 180 insertions(+), 94 deletions(-) diff --git a/src/dateTime/dateTime.ts b/src/dateTime/dateTime.ts index 1d636e0..e16fb9f 100644 --- a/src/dateTime/dateTime.ts +++ b/src/dateTime/dateTime.ts @@ -37,45 +37,14 @@ 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}) { 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 = dayjs.createDayjs(opt.ts, opt.timeZone, opt.offset, opt.locale); } format(formatInput?: FormatInput) { @@ -113,7 +82,7 @@ class DateTimeImpl implements DateTime { ts -= (newOffset - this._offset) * 60 * 1000; } return createDateTime({ - input: ts, + ts, timeZone: UtcTimeZone, offset: newOffset, locale: this._locale, @@ -130,15 +99,15 @@ class DateTimeImpl implements DateTime { return 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 { @@ -150,27 +119,43 @@ class DateTimeImpl implements DateTime { } startOf(unitOfTime: StartOfUnit): DateTime { + if (!this.isValid()) { + return this; + } + // 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(); + let offset = this._offset; + if (this._timeZone !== UtcTimeZone) { + offset = timeZoneOffset(this._timeZone, ts); + } return createDateTime({ - input: ts, + ts, timeZone: this._timeZone, - offset: this._offset, + offset, locale: this._locale, }); } endOf(unitOfTime: StartOfUnit): DateTime { + if (!this.isValid()) { + return this; + } + // 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(); + let offset = this._offset; + if (this._timeZone !== UtcTimeZone) { + offset = timeZoneOffset(this._timeZone, ts); + } return createDateTime({ - input: ts, + ts, timeZone: this._timeZone, - offset: this._offset, + offset, locale: this._locale, }); } @@ -242,10 +227,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 { @@ -259,7 +244,7 @@ class DateTimeImpl implements DateTime { if (keepLocalTime) { ts += this._offset * 60 * 1000; } - return new DateTimeImpl({input: ts, timeZone: UtcTimeZone}); + return createDateTime({ts, timeZone: UtcTimeZone, offset: 0, locale: this._locale}); } daysInMonth(): number { return this._date.daysInMonth(); @@ -288,7 +273,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 +296,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, }); } @@ -436,32 +422,40 @@ function addSubtract( unit: DurationUnit | undefined, sign: 1 | -1, ) { + const timeZone = instance.timeZone(); + let ts = instance.valueOf(); + let offset = instance.utcOffset(); + const duration = getDuration(amount, unit); - const dateComponents = tsToObject(instance.valueOf(), instance.utcOffset()); + const dateComponents = tsToObject(ts, offset); 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; + if (timeZone === UtcTimeZone) { + ts -= offset * 60 * 1000; } else { - ts = fixOffset(ts, instance.utcOffset(), instance.timeZone())[0]; + [ts, offset] = fixOffset(ts, offset, timeZone); + } + } + + if (duration.milliseconds) { + ts += sign * duration.milliseconds; + if (timeZone !== UtcTimeZone) { + offset = timeZoneOffset(timeZone, ts); } } - ts += sign * duration.milliseconds; return createDateTime({ - input: ts, - timeZone: instance.timeZone(), - offset: instance.utcOffset(), + ts, + timeZone, + offset, locale: instance.locale(), }); } @@ -472,18 +466,17 @@ 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}); + return new DateTimeImpl({ts, timeZone, offset, locale}); } /** @@ -502,15 +495,42 @@ 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); + + 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(); + } + + 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..c3db3b6 100644 --- a/src/dayjs.ts +++ b/src/dayjs.ts @@ -42,26 +42,64 @@ dayjs.extend((_, Dayjs, d) => { // 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(); + const ts = this.valueOf(); + let localOffset = new Date(ts).getTimezoneOffset(); let offset = timeZoneOffset(timeZone, ts); + let target: number | string = ts; - if (keepLocalTime) { - const oldOffset = this.utcOffset(); - ts += oldOffset * 60 * 1000; - [ts, offset] = fixOffset(ts, offset, timeZone); + if (localOffset !== -offset) { + if (keepLocalTime) { + const oldOffset = this.utcOffset(); + target += oldOffset * 60 * 1000; + [target, offset] = fixOffset(target, offset, timeZone); + } + + if (offset !== 0) { + target += offset * 60 * 1000; + [target, localOffset] = fixOffset(target, localOffset, 'system'); + localOffset = -localOffset; + } } - 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; + const ins = d(target, { + // @ts-expect-error get locale from current instance + locale: this.$L, + utc: offset === 0, + // @ts-expect-error private fields used by utc and timezone plugins + $offset: offset ? offset : undefined, + x: {$timezone: timeZone, $localOffset: localOffset}, + }); + return ins; + }; + + // @ts-expect-error used internally by DateTimeImpl + d.createDayjs = function (ts: number, timeZone: string, offset: number, locale: string) { + let localOffset = new Date(ts).getTimezoneOffset(); + let newTs = ts; + if (offset !== 0 && localOffset !== -offset) { + newTs += offset * 60 * 1000; + [newTs, localOffset] = fixOffset(newTs, localOffset, 'system'); + localOffset = -localOffset; + } + const ins = d(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 ins; }; }); +declare module 'dayjs' { + interface LocalDate { + (ts: number, timeZone: string, offset: number, locale: string): dayjs.Dayjs; + } + + const createDayjs: LocalDate; +} + export default dayjs; export type {ConfigTypeMap, ConfigType} from 'dayjs'; diff --git a/src/timeZone/timeZone.ts b/src/timeZone/timeZone.ts index af5f8e2..565d8ce 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; } } @@ -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: 'AC', + }; + 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; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 9ab50d8..b461999 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -114,6 +114,9 @@ const normalizedUnits = { E: 'isoWeekday', isoweekday: 'isoWeekday', isoweekdays: 'isoWeekday', + weekday: 'day', + weekdays: 'day', + e: 'day', } as const; function normalizeComponent(component: string) { From 2702aa9aa23dc6a34c2fcecb6198a52e0e07c4f8 Mon Sep 17 00:00:00 2001 From: Valeriy Sidorenko Date: Wed, 22 Nov 2023 18:07:40 +0100 Subject: [PATCH 2/5] fix: work with years from 0 to 100 --- src/dateTime/dateTime.test.ts | 15 ++ src/dateTime/dateTime.ts | 285 +++++++++++++++++++++------------- src/dayjs.ts | 34 ++-- src/settings/settings.ts | 6 +- src/timeZone/timeZone.ts | 6 +- src/typings/dateTime.ts | 8 +- src/utils/utils.ts | 6 +- tsconfig.json | 3 +- 8 files changed, 221 insertions(+), 142 deletions(-) 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 e16fb9f..f54a568 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, @@ -96,7 +97,7 @@ 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; } const zone = normalizeTimeZone(timeZone, settings.getDefaultTimeZone()); @@ -111,57 +112,105 @@ class DateTimeImpl implements DateTime { } 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 { + startOf(unitOfTime: StartOfUnit | 'weekNumber' | 'isoWeekNumber' | 'isoWeekday') { if (!this.isValid()) { return this; } - // 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(); - let offset = this._offset; - if (this._timeZone !== UtcTimeZone) { - offset = timeZoneOffset(this._timeZone, ts); + 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; + } } - return createDateTime({ - ts, - timeZone: this._timeZone, - offset, - locale: this._locale, - }); + /* eslint-enable no-fallthrough */ + + return this.set(dateComponents); } - endOf(unitOfTime: StartOfUnit): DateTime { + endOf(unitOfTime: StartOfUnit | 'weekNumber' | 'isoWeekNumber' | 'isoWeekday'): DateTime { if (!this.isValid()) { return this; } - // 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(); - let offset = this._offset; - if (this._timeZone !== UtcTimeZone) { - offset = timeZoneOffset(this._timeZone, ts); + 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; + } } - return createDateTime({ - ts, - timeZone: this._timeZone, - offset, - locale: this._locale, - }); + /* eslint-enable no-fallthrough */ + + return this.set(dateComponents); } local(keepLocalTime?: boolean): DateTime { - return this.timeZone('default', keepLocalTime); + return this.timeZone('system', keepLocalTime); } valueOf(): number { @@ -169,27 +218,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 { @@ -199,7 +252,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: @@ -208,7 +261,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); @@ -240,11 +293,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 createDateTime({ts, timeZone: UtcTimeZone, offset: 0, locale: this._locale}); + return this.timeZone(UtcTimeZone, keepLocalTime); } daysInMonth(): number { return this._date.daysInMonth(); @@ -410,54 +459,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 timeZone = instance.timeZone(); - let ts = instance.valueOf(); - let offset = instance.utcOffset(); - - 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); + 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); + } } - } - if (duration.milliseconds) { - ts += sign * duration.milliseconds; - if (timeZone !== UtcTimeZone) { - offset = timeZoneOffset(timeZone, ts); + if (duration.milliseconds) { + ts += sign * duration.milliseconds; + if (timeZone !== UtcTimeZone) { + offset = timeZoneOffset(timeZone, ts); + } } - } - return createDateTime({ - ts, - timeZone, - offset, - locale: instance.locale(), - }); + return createDateTime({ + ts, + timeZone, + offset, + locale: this._locale, + }); + } } function absRound(v: number) { @@ -479,6 +533,28 @@ function createDateTime({ return new DateTimeImpl({ts, timeZone, offset, locale}); } +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; +} + /** * Checks if value is DateTime. * @param {unknown} value - value to check. @@ -506,22 +582,7 @@ export function dateTime(opt?: { const timeZoneOrDefault = normalizeTimeZone(timeZone, settings.getDefaultTimeZone()); 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(); - } + const ts = getTimestamp(input, format, lang); const offset = timeZoneOffset(timeZoneOrDefault, ts); diff --git a/src/dayjs.ts b/src/dayjs.ts index c3db3b6..50fccb7 100644 --- a/src/dayjs.ts +++ b/src/dayjs.ts @@ -43,22 +43,19 @@ dayjs.extend((_, Dayjs, d) => { // but should be '2023-10-29T03:00:00+03:00' proto.tz = function (timeZone: string, keepLocalTime = false) { const ts = this.valueOf(); - let localOffset = new Date(ts).getTimezoneOffset(); + let localOffset = timeZoneOffset('system', ts); let offset = timeZoneOffset(timeZone, ts); let target: number | string = ts; - if (localOffset !== -offset) { - if (keepLocalTime) { - const oldOffset = this.utcOffset(); - target += oldOffset * 60 * 1000; - [target, offset] = fixOffset(target, offset, timeZone); - } + const oldOffset = this.utcOffset(); + if (keepLocalTime && oldOffset !== offset) { + target += oldOffset * 60 * 1000; + [target, offset] = fixOffset(target, offset, timeZone); + } - if (offset !== 0) { - target += offset * 60 * 1000; - [target, localOffset] = fixOffset(target, localOffset, 'system'); - localOffset = -localOffset; - } + if (offset !== 0 && localOffset !== offset) { + target += offset * 60 * 1000; + [target, localOffset] = fixOffset(target, localOffset, 'system'); } const ins = d(target, { @@ -67,26 +64,29 @@ dayjs.extend((_, Dayjs, d) => { utc: offset === 0, // @ts-expect-error private fields used by utc and timezone plugins $offset: offset ? offset : undefined, - x: {$timezone: timeZone, $localOffset: localOffset}, + x: {$timezone: timeZone, $localOffset: -localOffset}, }); return ins; }; // @ts-expect-error used internally by DateTimeImpl d.createDayjs = function (ts: number, timeZone: string, offset: number, locale: string) { - let localOffset = new Date(ts).getTimezoneOffset(); + if (timeZone === 'system') { + return d(ts, {locale}); + } + + let localOffset = timeZoneOffset('system', ts); let newTs = ts; - if (offset !== 0 && localOffset !== -offset) { + if (offset !== 0 && localOffset !== offset) { newTs += offset * 60 * 1000; [newTs, localOffset] = fixOffset(newTs, localOffset, 'system'); - localOffset = -localOffset; } const ins = d(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}, + x: {$timezone: timeZone, $localOffset: -localOffset}, }); return ins; }; 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 565d8ce..f529fe6 100644 --- a/src/timeZone/timeZone.ts +++ b/src/timeZone/timeZone.ts @@ -39,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', @@ -85,7 +85,7 @@ export function timeZoneOffset(zone: TimeZone, ts: number) { hour: 0, minute: 0, second: 0, - era: 'AC', + era: 'AD', }; for (const {type, value} of formatted) { if (type === 'era') { @@ -128,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..c501fa9 100644 --- a/src/typings/dateTime.ts +++ b/src/typings/dateTime.ts @@ -72,13 +72,13 @@ 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; - isSame(input?: DateTimeInput, granularity?: StartOfUnit): boolean; - isBefore(input?: DateTimeInput): boolean; - isAfter(input?: DateTimeInput): boolean; + isSame(input: DateTimeInput, granularity?: StartOfUnit): boolean; + isBefore(input: DateTimeInput): boolean; + isAfter(input: DateTimeInput): boolean; isValid(): boolean; local(keepLocalTime?: boolean): DateTime; locale(): string; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index b461999..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,9 +105,11 @@ const normalizedUnits = { d: 'day', day: 'day', days: 'day', + weeknumber: 'weekNumber', w: 'weekNumber', week: 'weekNumber', weeks: 'weekNumber', + isoweeknumber: 'isoWeekNumber', W: 'isoWeekNumber', isoweek: 'isoWeekNumber', isoweeks: 'isoWeekNumber', @@ -119,7 +121,7 @@ const normalizedUnits = { 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"] From 1a29931cc943f1a332a1037811c6beb4a469f491 Mon Sep 17 00:00:00 2001 From: Valeriy Sidorenko Date: Thu, 23 Nov 2023 11:33:36 +0100 Subject: [PATCH 3/5] fix: do not patch dayjs object --- src/dateTime/dateTime.ts | 31 ++++++++++++++++-- src/dayjs.ts | 71 ---------------------------------------- 2 files changed, 28 insertions(+), 74 deletions(-) diff --git a/src/dateTime/dateTime.ts b/src/dateTime/dateTime.ts index f54a568..1e47bed 100644 --- a/src/dateTime/dateTime.ts +++ b/src/dateTime/dateTime.ts @@ -38,14 +38,20 @@ class DateTimeImpl implements DateTime { private _locale: string; private _date: dayjs.Dayjs; - constructor(opt: {ts: number; timeZone: TimeZone; offset: number; locale: string}) { + constructor(opt: { + ts: number; + timeZone: TimeZone; + offset: number; + locale: string; + date: dayjs.Dayjs; + }) { this[IS_DATE_TIME] = true; this._timestamp = opt.ts; this._locale = opt.locale; this._timeZone = opt.timeZone; this._offset = opt.offset; - this._date = dayjs.createDayjs(opt.ts, opt.timeZone, opt.offset, opt.locale); + this._date = opt.date; } format(formatInput?: FormatInput) { @@ -530,7 +536,26 @@ function createDateTime({ offset: number; locale: string; }): DateTime { - return new DateTimeImpl({ts, timeZone, offset, 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) { diff --git a/src/dayjs.ts b/src/dayjs.ts index 50fccb7..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,75 +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) { - const ts = this.valueOf(); - let localOffset = timeZoneOffset('system', ts); - let offset = timeZoneOffset(timeZone, ts); - let target: number | string = ts; - - const oldOffset = this.utcOffset(); - if (keepLocalTime && oldOffset !== offset) { - target += oldOffset * 60 * 1000; - [target, offset] = fixOffset(target, offset, timeZone); - } - - if (offset !== 0 && localOffset !== offset) { - target += offset * 60 * 1000; - [target, localOffset] = fixOffset(target, localOffset, 'system'); - } - - const ins = d(target, { - // @ts-expect-error get locale from current instance - locale: this.$L, - utc: offset === 0, - // @ts-expect-error private fields used by utc and timezone plugins - $offset: offset ? offset : undefined, - x: {$timezone: timeZone, $localOffset: -localOffset}, - }); - return ins; - }; - - // @ts-expect-error used internally by DateTimeImpl - d.createDayjs = function (ts: number, timeZone: string, offset: number, locale: string) { - if (timeZone === 'system') { - return d(ts, {locale}); - } - - let localOffset = timeZoneOffset('system', ts); - let newTs = ts; - if (offset !== 0 && localOffset !== offset) { - newTs += offset * 60 * 1000; - [newTs, localOffset] = fixOffset(newTs, localOffset, 'system'); - } - const ins = d(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 ins; - }; -}); - -declare module 'dayjs' { - interface LocalDate { - (ts: number, timeZone: string, offset: number, locale: string): dayjs.Dayjs; - } - - const createDayjs: LocalDate; -} - export default dayjs; export type {ConfigTypeMap, ConfigType} from 'dayjs'; From 2ca88b932b2cd6e3f242aacdb100d255c5540991 Mon Sep 17 00:00:00 2001 From: Valeriy Sidorenko Date: Thu, 23 Nov 2023 11:34:21 +0100 Subject: [PATCH 4/5] test: add more tests --- src/dateTime/__tests__/diff.ts | 176 +++++++++++++++++++++++++++++++++ src/dateTime/__tests__/from.ts | 26 +++++ 2 files changed, 202 insertions(+) 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); +}); From 123deba8f2f57310e6317750711f4273e34e419b Mon Sep 17 00:00:00 2001 From: Valeriy Sidorenko Date: Thu, 23 Nov 2023 12:32:00 +0100 Subject: [PATCH 5/5] fix: rollback types changes --- src/typings/dateTime.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/typings/dateTime.ts b/src/typings/dateTime.ts index c501fa9..9f1c515 100644 --- a/src/typings/dateTime.ts +++ b/src/typings/dateTime.ts @@ -76,9 +76,9 @@ export interface DateTime extends Object { format(formatInput?: FormatInput): string; fromNow(withoutSuffix?: boolean): string; from(formaInput: DateTimeInput, withoutSuffix?: boolean): string; - isSame(input: DateTimeInput, granularity?: StartOfUnit): boolean; - isBefore(input: DateTimeInput): boolean; - isAfter(input: DateTimeInput): boolean; + isSame(input?: DateTimeInput, granularity?: StartOfUnit): boolean; + isBefore(input?: DateTimeInput): boolean; + isAfter(input?: DateTimeInput): boolean; isValid(): boolean; local(keepLocalTime?: boolean): DateTime; locale(): string;