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"]