From 28fbfd81251d0d56229d4cb2fa56d12d143f2231 Mon Sep 17 00:00:00 2001 From: Valerii Sidorenko Date: Wed, 22 May 2024 16:29:45 +0200 Subject: [PATCH] feat(DateTime): use own format and all dates math, remove unused dayjs plugins --- package-lock.json | 8 +- package.json | 2 +- scripts/updateLocales.js | 1 + src/constants/format.ts | 1 + src/dateTime/__tests__/relative.ts | 61 ++++ src/dateTime/__tests__/weekday.ts | 82 +++++- src/dateTime/dateTime.test.ts | 14 +- src/dateTime/dateTime.ts | 350 ++++++++++++++++------- src/dateTime/format.ts | 436 +++++++++++++++++++++++++++++ src/dateTime/index.ts | 1 + src/dateTime/parse.ts | 64 +++++ src/dateTime/relative.ts | 76 +++++ src/dayjs/dayjs.d.ts | 10 - src/dayjs/index.ts | 35 +-- src/duration/__tests__/format.ts | 4 +- src/duration/__tests__/parse.ts | 2 +- src/index.ts | 2 +- src/settings/settings.ts | 6 +- src/settings/types.ts | 51 +++- src/timeZone/timeZone.ts | 37 ++- src/typings/dateTime.ts | 56 ++-- src/utils/utils.ts | 191 ++++++++++++- 22 files changed, 1298 insertions(+), 192 deletions(-) create mode 100644 src/dateTime/__tests__/relative.ts create mode 100644 src/dateTime/format.ts create mode 100644 src/dateTime/parse.ts create mode 100644 src/dateTime/relative.ts delete mode 100644 src/dayjs/dayjs.d.ts diff --git a/package-lock.json b/package-lock.json index aafcb8e..dbc5852 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "prettier": "^3.1.0", "sinon": "^10.0.1", "ts-jest": "^29.1.1", - "typescript": "^5.2.2" + "typescript": "^5.4.5" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -10068,9 +10068,9 @@ } }, "node_modules/typescript": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", - "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", "dev": true, "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index 620f255..521234a 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "prettier": "^3.1.0", "sinon": "^10.0.1", "ts-jest": "^29.1.1", - "typescript": "^5.2.2" + "typescript": "^5.4.5" }, "dependencies": { "dayjs": "1.11.10", diff --git a/scripts/updateLocales.js b/scripts/updateLocales.js index 6adbf44..2fc8e85 100644 --- a/scripts/updateLocales.js +++ b/scripts/updateLocales.js @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ /** * Fetches and writes to the locale file from github dayjs * node.js >= 20 lts diff --git a/src/constants/format.ts b/src/constants/format.ts index a44d572..9e39128 100644 --- a/src/constants/format.ts +++ b/src/constants/format.ts @@ -1 +1,2 @@ export const DEFAULT_SYSTEM_DATE_FORMAT = 'YYYY-MM-DD'; +export const INVALID_DATE_STRING = 'Invalid Date'; diff --git a/src/dateTime/__tests__/relative.ts b/src/dateTime/__tests__/relative.ts new file mode 100644 index 0000000..f5d7077 --- /dev/null +++ b/src/dateTime/__tests__/relative.ts @@ -0,0 +1,61 @@ +import {dateTime} from '../dateTime'; + +test('from', () => { + const start = dateTime(); + expect(start.from(start.add(5, 'seconds'))).toBe('a few seconds ago'); + expect(start.from(start.add(1, 'minute'))).toBe('a minute ago'); + expect(start.from(start.add(5, 'minutes'))).toBe('5 minutes ago'); + + expect(start.from(start.subtract(5, 'seconds'))).toBe('in a few seconds'); + expect(start.from(start.subtract(1, 'minute'))).toBe('in a minute'); + expect(start.from(start.subtract(5, 'minutes'))).toBe('in 5 minutes'); +}); + +test('from with absolute duration', () => { + const start = dateTime(); + expect(start.from(start.add(5, 'seconds'), true)).toBe('a few seconds'); + expect(start.from(start.add(1, 'minute'), true)).toBe('a minute'); + expect(start.from(start.add(5, 'minutes'), true)).toBe('5 minutes'); + + expect(start.from(start.subtract(5, 'seconds'), true)).toBe('a few seconds'); + expect(start.from(start.subtract(1, 'minute'), true)).toBe('a minute'); + expect(start.from(start.subtract(5, 'minutes'), true)).toBe('5 minutes'); +}); + +test('default thresholds fromNow', () => { + let a = dateTime(); + + // Seconds to minutes threshold + a = a.subtract(44, 'seconds'); + expect(a.fromNow()).toBe('a few seconds ago'); //'Below default seconds to minutes threshold' + a = a.subtract(1, 'seconds'); + expect(a.fromNow()).toBe('a minute ago'); // 'Above default seconds to minutes threshold' + + // Minutes to hours threshold + a = dateTime(); + a = a.subtract(44, 'minutes'); + expect(a.fromNow()).toBe('44 minutes ago'); //'Below default minute to hour threshold' + a = a.subtract(1, 'minutes'); + expect(a.fromNow()).toBe('an hour ago'); // 'Above default minute to hour threshold' + + // Hours to days threshold + a = dateTime(); + a = a.subtract(21, 'hours'); + expect(a.fromNow()).toBe('21 hours ago'); // 'Below default hours to day threshold' + a = a.subtract(1, 'hours'); + expect(a.fromNow()).toBe('a day ago'); // 'Above default hours to day threshold' + + // Days to month threshold + a = dateTime(); + a = a.subtract(25, 'days'); + expect(a.fromNow()).toBe('25 days ago'); // 'Below default days to month (singular) threshold' + a = a.subtract(1, 'days'); + expect(a.fromNow()).toBe('a month ago'); // 'Above default days to month (singular) threshold' + + // months to year threshold + a = dateTime(); + a = a.subtract(10, 'months'); + expect(a.fromNow()).toBe('10 months ago'); // 'Below default days to years threshold' + a = a.subtract(1, 'month'); + expect(a.fromNow()).toBe('a year ago'); // 'Above default days to years threshold' +}); diff --git a/src/dateTime/__tests__/weekday.ts b/src/dateTime/__tests__/weekday.ts index 05a9aa0..799b34c 100644 --- a/src/dateTime/__tests__/weekday.ts +++ b/src/dateTime/__tests__/weekday.ts @@ -6,10 +6,7 @@ afterEach(() => { }); test('iso weekday', () => { - let i; - - for (i = 0; i < 7; ++i) { - // moment.locale('dow:' + i + ',doy: 6', {week: {dow: i, doy: 6}}); + for (let i = 0; i < 7; ++i) { settings.updateLocale({weekStart: i, yearStart: 1 + i}); expect(dateTime({input: [1985, 1, 4]}).isoWeekday()).toBe(1); // 'Feb 4 1985 is Monday -- 1st day' expect(dateTime({input: [2029, 8, 18]}).isoWeekday()).toBe(2); // 'Sep 18 2029 is Tuesday -- 2nd day' @@ -55,3 +52,80 @@ test('iso weekday setter', () => { expect(a.isoWeekday(11).date()).toBe(20); // 'set from sun to next thu' expect(a.isoWeekday(14).date()).toBe(23); // 'set from sun to next sun' }); + +test('weekday first day of week Sunday', () => { + settings.updateLocale({weekStart: 0, yearStart: 1}); + expect(dateTime({input: [1985, 1, 3]}).weekday()).toBe(0); // 'Feb 3 1985 is Sunday -- 0th day' + expect(dateTime({input: [2029, 8, 17]}).weekday()).toBe(1); // 'Sep 17 2029 is Monday -- 1st day' + expect(dateTime({input: [2013, 3, 23]}).weekday()).toBe(2); // 'Apr 23 2013 is Tuesday -- 2nd day' + expect(dateTime({input: [2015, 2, 4]}).weekday()).toBe(3); // 'Mar 4 2015 is Wednesday -- 3nd day' + expect(dateTime({input: [1970, 0, 1]}).weekday()).toBe(4); // 'Jan 1 1970 is Thursday -- 4th day' + expect(dateTime({input: [2001, 4, 11]}).weekday()).toBe(5); // 'May 11 2001 is Friday -- 5th day' + expect(dateTime({input: [2000, 0, 1]}).weekday()).toBe(6); // 'Jan 1 2000 is Saturday -- 6th day' +}); + +test('weekday first day of week Monday', () => { + settings.updateLocale({weekStart: 1, yearStart: 1}); + expect(dateTime({input: [1985, 1, 4]}).weekday()).toBe(0); // 'Feb 4 1985 is Monday -- 0th day' + expect(dateTime({input: [2029, 8, 18]}).weekday()).toBe(1); // 'Sep 18 2029 is Tuesday -- 1st day' + expect(dateTime({input: [2013, 3, 24]}).weekday()).toBe(2); // 'Apr 24 2013 is Wednesday -- 2nd day' + expect(dateTime({input: [2015, 2, 5]}).weekday()).toBe(3); // 'Mar 5 2015 is Thursday -- 3nd day' + expect(dateTime({input: [1970, 0, 2]}).weekday()).toBe(4); // 'Jan 2 1970 is Friday -- 4th day' + expect(dateTime({input: [2001, 4, 12]}).weekday()).toBe(5); // 'May 12 2001 is Saturday -- 5th day' + expect(dateTime({input: [2000, 0, 2]}).weekday()).toBe(6); // 'Jan 2 2000 is Sunday -- 6th day' +}); + +test('weekday first day of week Tuesday', () => { + settings.updateLocale({weekStart: 2, yearStart: 1}); + expect(dateTime({input: [1985, 1, 5]}).weekday()).toBe(0); // 'Feb 5 1985 is Tuesday -- 0th day' + expect(dateTime({input: [2029, 8, 19]}).weekday()).toBe(1); // 'Sep 19 2029 is Wednesday -- 1st day' + expect(dateTime({input: [2013, 3, 25]}).weekday()).toBe(2); // 'Apr 25 2013 is Thursday -- 2nd day' + expect(dateTime({input: [2015, 2, 6]}).weekday()).toBe(3); // 'Mar 6 2015 is Friday -- 3nd day' + expect(dateTime({input: [1970, 0, 3]}).weekday()).toBe(4); // 'Jan 3 1970 is Saturday -- 4th day' + expect(dateTime({input: [2001, 4, 13]}).weekday()).toBe(5); // 'May 13 2001 is Sunday -- 5th day' + expect(dateTime({input: [2000, 0, 3]}).weekday()).toBe(6); // 'Jan 3 2000 is Monday -- 6th day' +}); + +test('weekday first day of week Wednesday', () => { + settings.updateLocale({weekStart: 3, yearStart: 1}); + expect(dateTime({input: [1985, 1, 6]}).weekday()).toBe(0); // 'Feb 6 1985 is Wednesday -- 0th day' + expect(dateTime({input: [2029, 8, 20]}).weekday()).toBe(1); // 'Sep 20 2029 is Thursday -- 1st day' + expect(dateTime({input: [2013, 3, 26]}).weekday()).toBe(2); // 'Apr 26 2013 is Friday -- 2nd day' + expect(dateTime({input: [2015, 2, 7]}).weekday()).toBe(3); // 'Mar 7 2015 is Saturday -- 3nd day' + expect(dateTime({input: [1970, 0, 4]}).weekday()).toBe(4); // 'Jan 4 1970 is Sunday -- 4th day' + expect(dateTime({input: [2001, 4, 14]}).weekday()).toBe(5); // 'May 14 2001 is Monday -- 5th day' + expect(dateTime({input: [2000, 0, 4]}).weekday()).toBe(6); // 'Jan 4 2000 is Tuesday -- 6th day' +}); + +test('weekday first day of week Thursday', () => { + settings.updateLocale({weekStart: 4, yearStart: 1}); + expect(dateTime({input: [1985, 1, 7]}).weekday()).toBe(0); // 'Feb 7 1985 is Thursday -- 0th day' + expect(dateTime({input: [2029, 8, 21]}).weekday()).toBe(1); // 'Sep 21 2029 is Friday -- 1st day' + expect(dateTime({input: [2013, 3, 27]}).weekday()).toBe(2); // 'Apr 27 2013 is Saturday -- 2nd day' + expect(dateTime({input: [2015, 2, 8]}).weekday()).toBe(3); // 'Mar 8 2015 is Sunday -- 3nd day' + expect(dateTime({input: [1970, 0, 5]}).weekday()).toBe(4); // 'Jan 5 1970 is Monday -- 4th day' + expect(dateTime({input: [2001, 4, 15]}).weekday()).toBe(5); // 'May 15 2001 is Tuesday -- 5th day' + expect(dateTime({input: [2000, 0, 5]}).weekday()).toBe(6); // 'Jan 5 2000 is Wednesday -- 6th day' +}); + +test('weekday first day of week Friday', () => { + settings.updateLocale({weekStart: 5, yearStart: 1}); + expect(dateTime({input: [1985, 1, 8]}).weekday()).toBe(0); // 'Feb 8 1985 is Friday -- 0th day' + expect(dateTime({input: [2029, 8, 22]}).weekday()).toBe(1); // 'Sep 22 2029 is Saturday -- 1st day' + expect(dateTime({input: [2013, 3, 28]}).weekday()).toBe(2); // 'Apr 28 2013 is Sunday -- 2nd day' + expect(dateTime({input: [2015, 2, 9]}).weekday()).toBe(3); // 'Mar 9 2015 is Monday -- 3nd day' + expect(dateTime({input: [1970, 0, 6]}).weekday()).toBe(4); // 'Jan 6 1970 is Tuesday -- 4th day' + expect(dateTime({input: [2001, 4, 16]}).weekday()).toBe(5); // 'May 16 2001 is Wednesday -- 5th day' + expect(dateTime({input: [2000, 0, 6]}).weekday()).toBe(6); // 'Jan 6 2000 is Thursday -- 6th day' +}); + +test('weekday first day of week Saturday', () => { + settings.updateLocale({weekStart: 6, yearStart: 1}); + expect(dateTime({input: [1985, 1, 9]}).weekday()).toBe(0); // 'Feb 9 1985 is Saturday -- 0th day' + expect(dateTime({input: [2029, 8, 23]}).weekday()).toBe(1); // 'Sep 23 2029 is Sunday -- 1st day' + expect(dateTime({input: [2013, 3, 29]}).weekday()).toBe(2); // 'Apr 29 2013 is Monday -- 2nd day' + expect(dateTime({input: [2015, 2, 10]}).weekday()).toBe(3); // 'Mar 10 2015 is Tuesday -- 3nd day' + expect(dateTime({input: [1970, 0, 7]}).weekday()).toBe(4); // 'Jan 7 1970 is Wednesday -- 4th day' + expect(dateTime({input: [2001, 4, 17]}).weekday()).toBe(5); // 'May 17 2001 is Thursday -- 5th day' + expect(dateTime({input: [2000, 0, 7]}).weekday()).toBe(6); // 'Jan 7 2000 is Friday -- 6th day' +}); diff --git a/src/dateTime/dateTime.test.ts b/src/dateTime/dateTime.test.ts index 94d3867..f4a7343 100644 --- a/src/dateTime/dateTime.test.ts +++ b/src/dateTime/dateTime.test.ts @@ -214,7 +214,7 @@ describe('DateTime', () => { }); it('should work with years >= 0 and < 100 ', () => { - const date = dateTime({input: '0001-01-12T00:00:00Z', timeZone: 'Europe/Amsterdam'}); + let 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()); @@ -226,6 +226,18 @@ describe('DateTime', () => { expect(date.isSame(date)).toBe(true); expect(date.valueOf()).toBe(date.startOf('ms').valueOf()); + + date = dateTime({input: '2023-01-01T00:00:00.000Z', timeZone: 'Europe/Moscow'}); + const date2 = date.set('y', 2); + const date3 = date2.set({h: date2.hour(), m: date2.minute(), s: date2.second()}); + expect(date3.toISOString()).toBe(date2.toISOString()); + + expect( + dateTime({ + input: '0002-01-01T00:00:00.000Z', + timeZone: 'Europe/Moscow', + }).toISOString(), + ).toBe('0002-01-01T00:00:00.000Z'); }); }); }); diff --git a/src/dateTime/dateTime.ts b/src/dateTime/dateTime.ts index 8f065a1..9b556f0 100644 --- a/src/dateTime/dateTime.ts +++ b/src/dateTime/dateTime.ts @@ -1,7 +1,8 @@ -import {STRICT, UtcTimeZone} from '../constants'; +import {INVALID_DATE_STRING, STRICT, UtcTimeZone} from '../constants'; import dayjs from '../dayjs'; import {duration} from '../duration'; import {settings} from '../settings'; +import type {Locale} from '../settings/types'; import {fixOffset, guessUserTimeZone, normalizeTimeZone, timeZoneOffset} from '../timeZone'; import type { AllUnit, @@ -15,13 +16,24 @@ import type { TimeZone, } from '../typings'; import { + computeOrdinal, daysInMonth, + gregorianToWeek, + monthDiff, normalizeComponent, normalizeDateComponents, + normalizeDurationUnit, objToTS, offsetFromString, tsToObject, + uncomputeOrdinal, + weekToGregorian, } from '../utils'; +import type {DateObject} from '../utils'; + +import {formatDate} from './format'; +import {getTimestampFromArray, getTimestampFromObject} from './parse'; +import {fromTo} from './relative'; const IS_DATE_TIME = Symbol('isDateTime'); class DateTimeImpl implements DateTime { @@ -37,14 +49,18 @@ class DateTimeImpl implements DateTime { private _timeZone: string; private _offset: number; private _locale: string; - private _date: dayjs.Dayjs; + private _c: DateObject; + private _weekInfo: ReturnType | null = null; + private _localeData: Locale; + private _isValid: boolean; constructor(opt: { ts: number; timeZone: TimeZone; offset: number; locale: string; - date: dayjs.Dayjs; + localeData: Locale; + isValid: boolean; }) { this[IS_DATE_TIME] = true; @@ -52,18 +68,27 @@ class DateTimeImpl implements DateTime { this._locale = opt.locale; this._timeZone = opt.timeZone; this._offset = opt.offset; - this._date = opt.date; + this._c = tsToObject(opt.ts, opt.offset); + this._localeData = opt.localeData; + this._isValid = opt.isValid; } - format(formatInput?: FormatInput) { - return this._date.format(formatInput); + format(formatInput?: FormatInput): string { + if (!this.isValid()) { + return this._localeData.invalidDate || INVALID_DATE_STRING; + } + + if (formatInput === undefined && this._offset === 0) { + return this.format('YYYY-MM-DDTHH:mm:ss[Z]'); + } + return formatDate(this, formatInput, this._localeData); } toISOString(keepOffset?: boolean): string { if (keepOffset) { - return this._date.format('YYYY-MM-DDTHH:mm:ss.SSSZ'); + return this.format('YYYY-MM-DDTHH:mm:ss.SSSZ'); } - return this._date.toISOString(); + return this.toDate().toISOString(); } utcOffset(): number; @@ -126,7 +151,15 @@ class DateTimeImpl implements DateTime { return this.addSubtract(amount, unit, -1); } - startOf(unitOfTime: StartOfUnit | 'weekNumber' | 'isoWeekNumber' | 'isoWeekday') { + startOf( + unitOfTime: + | StartOfUnit + | 'weekNumber' + | 'isoWeekNumber' + | 'weekday' + | 'isoWeekday' + | 'dayOfYear', + ) { if (!this.isValid()) { return this; } @@ -156,7 +189,9 @@ class DateTimeImpl implements DateTime { } case 'day': case 'date': + case 'weekday': case 'isoWeekday': + case 'dayOfYear': dateComponents.hour = 0; case 'hour': dateComponents.minute = 0; @@ -171,7 +206,15 @@ class DateTimeImpl implements DateTime { return this.set(dateComponents); } - endOf(unitOfTime: StartOfUnit | 'weekNumber' | 'isoWeekNumber' | 'isoWeekday'): DateTime { + endOf( + unitOfTime: + | StartOfUnit + | 'weekNumber' + | 'isoWeekNumber' + | 'weekday' + | 'isoWeekday' + | 'dayOfYear', + ): DateTime { if (!this.isValid()) { return this; } @@ -204,7 +247,9 @@ class DateTimeImpl implements DateTime { } case 'day': case 'date': + case 'weekday': case 'isoWeekday': + case 'dayOfYear': dateComponents.hour = 23; case 'hour': dateComponents.minute = 59; @@ -240,8 +285,8 @@ class DateTimeImpl implements DateTime { if (!this.isValid() || isNaN(ts)) { return false; } - const unit = normalizeComponent(granularity ?? 'millisecond'); - const localTs = unit === 'millisecond' ? this.valueOf() : this.endOf(unit).valueOf(); + const unit = normalizeDurationUnit(granularity ?? 'millisecond'); + const localTs = unit === 'milliseconds' ? this.valueOf() : this.endOf(unit).valueOf(); return localTs < ts; } @@ -250,13 +295,13 @@ class DateTimeImpl implements DateTime { if (!this.isValid() || isNaN(ts)) { return false; } - const unit = normalizeComponent(granularity ?? 'millisecond'); - const localTs = unit === 'millisecond' ? this.valueOf() : this.startOf(unit).valueOf(); + const unit = normalizeDurationUnit(granularity ?? 'millisecond'); + const localTs = unit === 'milliseconds' ? this.valueOf() : this.startOf(unit).valueOf(); return localTs > ts; } isValid(): boolean { - return this._date.isValid(); + return this._isValid; } diff( @@ -264,15 +309,71 @@ class DateTimeImpl implements DateTime { unit?: DurationUnit | undefined, asFloat?: boolean | undefined, ): number { - const value = DateTimeImpl.isDateTime(amount) ? amount.valueOf() : amount; - return this._date.diff(value, unit, asFloat); + if (!this.isValid()) { + return NaN; + } + const value = DateTimeImpl.isDateTime(amount) + ? amount.timeZone(this._timeZone) + : createDateTime({ + ts: getTimestamp(amount), + timeZone: this._timeZone, + locale: this._locale, + offset: this._offset, + }); + if (!value.isValid()) { + return NaN; + } + + const unitType = normalizeDurationUnit(unit || 'millisecond'); + const zoneDelta = (value.utcOffset() - this.utcOffset()) * 60_000; + let output = 0; + switch (unitType) { + case 'years': { + output = monthDiff(this, value) / 12; + break; + } + case 'quarters': { + output = monthDiff(this, value) / 3; + break; + } + case 'months': { + output = monthDiff(this, value); + break; + } + case 'weeks': { + output = (this.valueOf() - value.valueOf() - zoneDelta) / 604_800_000; + break; + } + case 'days': { + output = (this.valueOf() - value.valueOf() - zoneDelta) / 86_400_000; + break; + } + case 'hours': { + output = (this.valueOf() - value.valueOf()) / 3_600_000; + break; + } + case 'minutes': { + output = (this.valueOf() - value.valueOf()) / 60_000; + break; + } + case 'seconds': { + output = (this.valueOf() - value.valueOf()) / 1_000; + break; + } + default: { + output = this.valueOf() - value.valueOf(); + } + } + return asFloat ? output : Math.floor(Math.abs(output)) * Math.sign(output) || 0; } fromNow(withoutSuffix?: boolean | undefined): string { - return this._date.fromNow(withoutSuffix); + return this.from(dateTime({timeZone: this._timeZone, lang: this._locale}), withoutSuffix); } from(formaInput: DateTimeInput, withoutSuffix?: boolean): string { - const value = DateTimeImpl.isDateTime(formaInput) ? formaInput.valueOf() : formaInput; - return this._date.from(value, withoutSuffix); + if (!this.isValid()) { + return INVALID_DATE_STRING; + } + return fromTo(this, formaInput, this._localeData.relativeTime, withoutSuffix, true); } locale(): string; locale(locale: string): DateTime; @@ -297,47 +398,81 @@ class DateTimeImpl implements DateTime { return this.timeZone(UtcTimeZone, keepLocalTime); } daysInMonth(): number { - return this._date.daysInMonth(); + return daysInMonth(this._c.year, this._c.month); } + // eslint-disable-next-line complexity set(unit: AllUnit | SetObject, amount?: number): DateTime { - const dateComponents = tsToObject(this._timestamp, this._offset); + if (!this.isValid()) { + return this; + } + const dateComponents = this._c; const newComponents = normalizeDateComponents( typeof unit === 'object' ? unit : {[unit]: amount}, normalizeComponent, ); const settingWeekStuff = - newComponents.weekNumber !== undefined || newComponents.day !== undefined || + newComponents.weekNumber !== undefined || newComponents.isoWeekNumber !== undefined || + newComponents.weekday !== undefined || newComponents.isoWeekday !== undefined; - const containsYearOrMonthDay = - newComponents.year !== undefined || - newComponents.month !== undefined || - newComponents.date !== undefined; + const containsDayOfYear = newComponents.dayOfYear !== undefined; + const containsYear = newComponents.year !== undefined; + const containsMonthOrDate = + newComponents.month !== undefined || newComponents.date !== undefined; + const containsYearOrMonthDay = containsYear || containsMonthOrDate; - if (settingWeekStuff && containsYearOrMonthDay) { + if (settingWeekStuff && (containsYearOrMonthDay || containsDayOfYear)) { throw new Error("Can't mix weekYear/weekNumber units with year/month/day"); } + if (containsDayOfYear && containsMonthOrDate) { + throw new Error("Can't mix day of year with month/day"); + } + let mixed; if (settingWeekStuff) { - let date = dayjs.utc(objToTS({...dateComponents, ...newComponents})); - const toDayjsUnit = { - weekNumber: 'week', - day: 'day', - isoWeekNumber: 'isoWeek', - isoWeekday: 'isoWeekday', - } as const; - for (const u of ['weekNumber', 'day', 'isoWeekNumber', 'isoWeekday'] as const) { - const v = newComponents[u]; - if (v !== undefined) { - date = date[toDayjsUnit[u]](v) as dayjs.Dayjs; - } + const {weekday, weekNumber, isoWeekday, isoWeekNumber, day} = newComponents; + const hasLocalWeekData = weekday !== undefined || weekNumber !== undefined; + const hasIsoWeekData = + isoWeekday !== undefined || isoWeekNumber !== undefined || day !== undefined; + if (hasLocalWeekData && hasIsoWeekData) { + throw new Error("Can't mix local week with ISO week"); + } + const weekInfo = this.weekInfo(); + if (hasLocalWeekData) { + const {minDaysInFirstWeek, startOfWeek} = getLocaleWeekValues(this._localeData); + const weekData = { + weekday: (weekday ?? weekInfo.weekday) + 1, + weekNumber: weekNumber ?? weekInfo.weekNumber, + weekYear: weekInfo.weekYear, + }; + mixed = { + ...dateComponents, + ...newComponents, + ...weekToGregorian(weekData, minDaysInFirstWeek, startOfWeek), + }; + } else { + const weekData = { + weekday: isoWeekday ?? (day === undefined ? weekInfo.isoWeekday : day || 7), + weekNumber: isoWeekNumber ?? weekInfo.isoWeekNumber, + weekYear: weekInfo.isoWeekYear, + }; + mixed = {...dateComponents, ...newComponents, ...weekToGregorian(weekData, 4, 1)}; } - mixed = tsToObject(date.valueOf(), 0); + } else if (containsDayOfYear) { + mixed = { + ...dateComponents, + ...newComponents, + ...uncomputeOrdinal({ + ordinal: this.dayOfYear(), + ...dateComponents, + ...newComponents, + }), + }; } else { mixed = {...dateComponents, ...newComponents}; @@ -368,7 +503,7 @@ class DateTimeImpl implements DateTime { if (typeof value === 'number') { return this.set('date', value); } - return this._date.date(); + return this.isValid() ? this._c.date : NaN; } month(): number; month(value: number): DateTime; @@ -376,7 +511,7 @@ class DateTimeImpl implements DateTime { if (typeof value === 'number') { return this.set('month', value); } - return this._date.month(); + return this.isValid() ? this._c.month : NaN; } quarter(): number; quarter(value: number): DateTime; @@ -384,7 +519,7 @@ class DateTimeImpl implements DateTime { if (typeof value === 'number') { return this.set('quarter', value); } - return this._date.quarter(); + return this.isValid() ? Math.ceil((this._c.month + 1) / 3) : NaN; } year(): number; year(value: number): DateTime; @@ -392,7 +527,7 @@ class DateTimeImpl implements DateTime { if (typeof value === 'number') { return this.set('year', value); } - return this._date.year(); + return this.isValid() ? this._c.year : NaN; } day(): number; day(value: number): DateTime; @@ -400,17 +535,18 @@ class DateTimeImpl implements DateTime { if (typeof value === 'number') { return this.set('day', value); } - return this._date.day(); + return this.isValid() ? this.weekInfo().day : NaN; } isoWeekday(): number; - isoWeekday(day: number): DateTime; - isoWeekday(day?: number): number | DateTime { - if (day === undefined) { - return this._date.isoWeekday(); + isoWeekday(value: number): DateTime; + isoWeekday(value?: number): number | DateTime { + if (typeof value === 'number') { + // return this.day(this.day() % 7 ? day : day - 7); + return this.set('isoWeekday', value); } - return this.day(this.day() % 7 ? day : day - 7); + return this.isValid() ? this.weekInfo().isoWeekday : NaN; } hour(): number; @@ -419,7 +555,7 @@ class DateTimeImpl implements DateTime { if (typeof value === 'number') { return this.set('hour', value); } - return this._date.hour(); + return this.isValid() ? this._c.hour : NaN; } minute(): number; minute(value: number): DateTime; @@ -427,7 +563,7 @@ class DateTimeImpl implements DateTime { if (typeof value === 'number') { return this.set('minute', value); } - return this._date.minute(); + return this.isValid() ? this._c.minute : NaN; } second(): number; second(value: number): DateTime; @@ -435,7 +571,7 @@ class DateTimeImpl implements DateTime { if (typeof value === 'number') { return this.set('second', value); } - return this._date.second(); + return this.isValid() ? this._c.second : NaN; } millisecond(): number; millisecond(value: number): DateTime; @@ -443,7 +579,7 @@ class DateTimeImpl implements DateTime { if (typeof value === 'number') { return this.set('millisecond', value); } - return this._date.millisecond(); + return this.isValid() ? this._c.millisecond : NaN; } week(): number; week(value: number): DateTime; @@ -451,7 +587,7 @@ class DateTimeImpl implements DateTime { if (typeof value === 'number') { return this.set('week', value); } - return this._date.week(); + return this.isValid() ? this.weekInfo().weekNumber : NaN; } isoWeek(): number; isoWeek(value: number): DateTime; @@ -459,19 +595,41 @@ class DateTimeImpl implements DateTime { if (typeof value === 'number') { return this.set('isoWeek', value); } - return this._date.isoWeek(); + return this.isValid() ? this.weekInfo().isoWeekNumber : NaN; + } + weekday(): number; + weekday(value: number): DateTime; + weekday(value?: number): number | DateTime { + if (typeof value === 'number') { + return this.set('weekday', value); + } + return this.isValid() ? this.weekInfo().weekday : NaN; } - 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; + + dayOfYear(value: number): DateTime; + dayOfYear(): number; + dayOfYear(value?: number): number | DateTime { + if (typeof value === 'number') { + return this.set('dayOfYear', value); + } + return this.isValid() ? computeOrdinal(this._c) : NaN; } toString(): string { - return this._date.toString(); + return this.toDate().toUTCString(); + } + /** + * Returns a string representation of this DateTime appropriate for the REPL. + * @return {string} + */ + [Symbol.for('nodejs.util.inspect.custom')]() { + if (this.isValid()) { + return `DateTime { ts: ${this.toISOString()}, zone: ${this.timeZone()}, locale: ${this.locale()} }`; + } else { + return `DateTime { ${INVALID_DATE_STRING} }`; + } } + private addSubtract(amount: DurationInput, unit: DurationUnit | undefined, sign: 1 | -1) { if (!this.isValid()) { return this; @@ -519,10 +677,22 @@ class DateTimeImpl implements DateTime { locale: this._locale, }); } + private weekInfo() { + if (!this._weekInfo) { + const {startOfWeek, minDaysInFirstWeek} = getLocaleWeekValues(this._localeData); + this._weekInfo = gregorianToWeek(this._c, minDaysInFirstWeek, startOfWeek); + } + return this._weekInfo; + } +} + +function getLocaleWeekValues(localeData: {yearStart?: number; weekStart?: number}) { + const {weekStart, yearStart} = localeData; + return {startOfWeek: weekStart || 7, minDaysInFirstWeek: yearStart || 1}; } function absRound(v: number) { - const sign = v < 0 ? -1 : 1; + const sign = Math.sign(v); return Math.round(sign * v) * sign; } @@ -537,35 +707,26 @@ function createDateTime({ offset: number; locale: string; }): DateTime { - 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}); + const loc = locale || 'en'; + const localeData = dayjs.Ls[loc] as Locale; + const isValid = !isNaN(Number(new Date(ts))); + return new DateTimeImpl({ts, timeZone, offset, locale: loc, localeData, isValid}); } -function getTimestamp(input: DateTimeInput, format?: string, lang?: string) { - const locale = dayjs.locale(lang || settings.getLocale(), undefined, true); - +function getTimestamp(input: DateTimeInput, format?: string, lang?: string, utc = false) { let ts: number; - if (DateTimeImpl.isDateTime(input) || typeof input === 'number' || input instanceof Date) { + if (isDateTime(input) || typeof input === 'number' || input instanceof Date) { ts = Number(input); + } else if (input === null || input === undefined) { + ts = Date.now(); + } else if (Array.isArray(input)) { + ts = getTimestampFromArray(input, utc); + } else if (typeof input === 'object') { + ts = getTimestampFromObject(input, utc); + } else if (utc) { + ts = dayjs.utc(input, format, STRICT).valueOf(); } else { + const locale = dayjs.locale(lang || settings.getLocale(), undefined, true); const localDate = format ? dayjs(input, format, locale, STRICT) : dayjs(input, undefined, locale); @@ -579,9 +740,9 @@ function getTimestamp(input: DateTimeInput, format?: string, lang?: string) { * Checks if value is DateTime. * @param {unknown} value - value to check. */ -export const isDateTime = (value: unknown): value is DateTime => { +export function isDateTime(value: unknown): value is DateTime { return DateTimeImpl.isDateTime(value); -}; +} /** * Creates a DateTime instance. @@ -621,12 +782,7 @@ export function dateTimeUtc(opt?: {input?: DateTimeInput; format?: FormatInput; 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 { - ts = dayjs.utc(input, format, STRICT).valueOf(); - } + const ts = getTimestamp(input, format, lang, true); const date = createDateTime({ ts, diff --git a/src/dateTime/format.ts b/src/dateTime/format.ts new file mode 100644 index 0000000..0c7f7bc --- /dev/null +++ b/src/dateTime/format.ts @@ -0,0 +1,436 @@ +import {settings} from '../settings'; +import type {Locale, LongDateFormat} from '../settings/types'; +import {parseZoneInfo} from '../timeZone'; +import type {DateTime} from '../typings'; + +export const englishFormats = { + LTS: 'h:mm:ss A', + LT: 'h:mm A', + L: 'MM/DD/YYYY', + LL: 'MMMM D, YYYY', + LLL: 'MMMM D, YYYY h:mm A', + LLLL: 'dddd, MMMM D, YYYY h:mm A', +} satisfies LongDateFormat; + +function getShortLocalizedFormatFromLongLocalizedFormat(formatBis: string) { + return formatBis.replace( + /(\[[^\]]+])|(MMMM|MM|DD|dddd)/g, + (_: string, escapeSequence: string, localizedFormat: string) => + escapeSequence || localizedFormat.slice(1), + ); +} + +export function expandFormat( + format: string, + formats: LongDateFormat = settings.getLocaleData().formats ?? englishFormats, +) { + return format.replace( + /(\[[^\]]*])|(LTS?|l{1,4}|L{1,4})/g, + (_: string, escapeSequence: string, localizedFormat: keyof LongDateFormat) => { + if (localizedFormat) { + if (localizedFormat in englishFormats) { + return ( + formats[localizedFormat] || + englishFormats[localizedFormat as keyof typeof englishFormats] + ); + } + const LongLocalizedFormat = + localizedFormat.toUpperCase() as keyof typeof englishFormats; + return getShortLocalizedFormatFromLongLocalizedFormat( + formats[LongLocalizedFormat] || englishFormats[LongLocalizedFormat], + ); + } + return escapeSequence; + }, + ); +} + +export const FORMAT_DEFAULT = 'YYYY-MM-DDTHH:mm:ssZ'; + +const formattingTokens = + /(\[[^[]*\])|([Hh]mm(ss)?|Mo|M{1,4}|Do|DDDo|D{1,4}|d{2,4}|do?|w[o|w]?|W[o|W]?|Qo?|N{1,5}|YYYYYY|YYYYY|YYYY|YY|y{2,4}|yo?|gg(ggg?)?|GG(GGG?)?|e|E|a|A|hh?|HH?|kk?|mm?|ss?|S{1,9}|x|X|zz?|ZZ?|.)/g; + +const formatTokenFunctions: Record< + string, + (date: DateTime, locale: Locale, format: string) => string +> = {}; + +export function formatDate( + date: DateTime, + format = FORMAT_DEFAULT, + locale = settings.getLocaleData(), +) { + const expandedFormat = expandFormat(format, locale.formats); + return expandedFormat.replace(formattingTokens, (match: string) => { + if (formatTokenFunctions[match]) { + return formatTokenFunctions[match](date, locale, expandedFormat); + } + return match.replace(/^\[|\]$/g, ''); + }); +} + +formatTokenFunctions['YY'] = (date) => { + const y = date.year(); + return zeroPad(y % 100, 2); +}; + +formatTokenFunctions['YYYY'] = (date) => { + return zeroPad(date.year(), 4); +}; +formatTokenFunctions['YYYYY'] = (date) => { + return zeroPad(date.year(), 5); +}; +formatTokenFunctions['YYYYYY'] = (date) => { + return zeroPad(date.year(), 6, true); +}; + +formatTokenFunctions['M'] = (date) => { + return `${date.month() + 1}`; +}; + +formatTokenFunctions['MM'] = (date) => { + return zeroPad(date.month() + 1, 2); +}; + +formatTokenFunctions['Mo'] = (date, locale) => { + return `${locale.ordinal?.(date.month() + 1, 'M')}`; +}; + +formatTokenFunctions['MMM'] = (date, locale, format) => { + const month = date.month(); + return getShort({ + date, + format, + data: locale.monthsShort, + index: month, + fullData: locale.months, + maxLength: 3, + }); +}; + +formatTokenFunctions['MMMM'] = (date, locale, format) => { + const month = date.month(); + return getShort({ + date, + format, + data: locale.months, + index: month, + }); +}; + +formatTokenFunctions['w'] = (date) => { + return `${date.week()}`; +}; + +formatTokenFunctions['ww'] = (date) => { + return zeroPad(date.week(), 2); +}; + +formatTokenFunctions['wo'] = (date, locale) => { + return `${locale.ordinal?.(date.week(), 'w')}`; +}; + +formatTokenFunctions['W'] = (date) => { + return `${date.isoWeek()}`; +}; + +formatTokenFunctions['WW'] = (date) => { + return zeroPad(date.isoWeek(), 2); +}; + +formatTokenFunctions['Wo'] = (date, locale) => { + return `${locale.ordinal?.(date.isoWeek(), 'W')}`; +}; + +formatTokenFunctions['d'] = (date) => { + return `${date.day()}`; +}; + +formatTokenFunctions['do'] = (date, locale) => { + return `${locale.ordinal?.(date.day(), 'd')}`; +}; + +formatTokenFunctions['dd'] = (date, locale, format) => { + const day = date.day(); + return getShort({ + date, + format, + data: locale.weekdaysMin, + index: day, + fullData: locale.weekdays, + maxLength: 2, + }); +}; + +formatTokenFunctions['ddd'] = (date, locale, format) => { + const day = date.day(); + return getShort({ + date, + format, + data: locale.weekdaysShort, + index: day, + fullData: locale.weekdays, + maxLength: 3, + }); +}; + +formatTokenFunctions['dddd'] = (date, locale, format) => { + const day = date.day(); + return getShort({ + date, + format, + data: locale.weekdays, + index: day, + }); +}; + +formatTokenFunctions['e'] = (date) => { + return `${date.weekday()}`; +}; + +formatTokenFunctions['E'] = (date) => { + return `${date.isoWeekday()}`; +}; + +function hFormat(hours: number) { + return hours % 12 || 12; +} + +function kFormat(hours: number) { + return hours || 24; +} + +formatTokenFunctions['H'] = (date) => { + return `${date.hour()}`; +}; + +formatTokenFunctions['HH'] = (date) => { + return zeroPad(date.hour(), 2); +}; + +formatTokenFunctions['h'] = (date) => { + return `${hFormat(date.hour())}`; +}; + +formatTokenFunctions['hh'] = (date) => { + return zeroPad(hFormat(date.hour()), 2); +}; + +formatTokenFunctions['k'] = (date) => { + return `${kFormat(date.hour())}`; +}; + +formatTokenFunctions['kk'] = (date) => { + return zeroPad(kFormat(date.hour()), 2); +}; + +formatTokenFunctions['hmm'] = (date) => { + return `${hFormat(date.hour())}${zeroPad(date.minute(), 2)}`; +}; + +formatTokenFunctions['hmmss'] = (date) => { + return `${hFormat(date.hour())}${zeroPad(date.minute(), 2)}${zeroPad(date.second(), 2)}`; +}; + +formatTokenFunctions['Hmm'] = (date) => { + return `${date.hour()}${zeroPad(date.minute(), 2)}`; +}; + +formatTokenFunctions['Hmmss'] = (date) => { + return `${date.hour()}${zeroPad(date.minute(), 2)}${zeroPad(date.second(), 2)}`; +}; + +function meridiem(hour: number, _minute: number, isLowercase: boolean) { + const m = hour < 12 ? 'AM' : 'PM'; + return isLowercase ? m.toLowerCase() : m; +} + +formatTokenFunctions['a'] = (date, locale) => { + const func = locale.meridiem || meridiem; + return func(date.hour(), date.minute(), true); +}; + +formatTokenFunctions['A'] = (date, locale) => { + const func = locale.meridiem || meridiem; + return func(date.hour(), date.minute(), false); +}; + +formatTokenFunctions['Z'] = (date) => { + let offset = date.utcOffset(); + let sign = '+'; + if (offset < 0) { + offset = -offset; + sign = '-'; + } + // eslint-disable-next-line no-bitwise + return `${sign}${zeroPad(~~(offset / 60), 2)}:${zeroPad(~~offset % 60, 2)}`; +}; + +formatTokenFunctions['ZZ'] = (date) => { + let offset = date.utcOffset(); + let sign = '+'; + if (offset < 0) { + offset = -offset; + sign = '-'; + } + // eslint-disable-next-line no-bitwise + return `${sign}${zeroPad(~~(offset / 60), 2)}${zeroPad(~~offset % 60, 2)}`; +}; + +formatTokenFunctions['Q'] = (date) => { + return `${date.quarter()}`; +}; + +formatTokenFunctions['Qo'] = (date, locale) => { + return `${locale.ordinal?.(date.quarter(), 'Q')}`; +}; + +formatTokenFunctions['D'] = (date) => { + return `${date.date()}`; +}; + +formatTokenFunctions['DD'] = (date) => { + return zeroPad(date.date(), 2); +}; + +formatTokenFunctions['Do'] = (date, locale) => { + return `${locale.ordinal?.(date.date(), 'D')}`; +}; + +formatTokenFunctions['m'] = (date) => { + return `${date.minute()}`; +}; + +formatTokenFunctions['mm'] = (date) => { + return zeroPad(date.minute(), 2); +}; + +formatTokenFunctions['s'] = (date) => { + return `${date.second()}`; +}; + +formatTokenFunctions['ss'] = (date) => { + return zeroPad(date.second(), 2); +}; + +formatTokenFunctions['S'] = (date) => { + // eslint-disable-next-line no-bitwise + return `${~~(date.millisecond() / 100)}`; +}; + +formatTokenFunctions['SS'] = (date) => { + // eslint-disable-next-line no-bitwise + return `${~~(date.millisecond() / 10)}`; +}; + +formatTokenFunctions['SSS'] = (date) => { + return zeroPad(date.millisecond(), 3); +}; + +formatTokenFunctions['SSSS'] = (date) => { + return zeroPad(date.millisecond() * 10, 4); +}; + +formatTokenFunctions['SSSSS'] = (date) => { + return zeroPad(date.millisecond() * 100, 5); +}; + +formatTokenFunctions['SSSSSS'] = (date) => { + return zeroPad(date.millisecond() * 1000, 6); +}; + +formatTokenFunctions['SSSSSSS'] = (date) => { + return zeroPad(date.millisecond() * 10000, 7); +}; + +formatTokenFunctions['SSSSSSSS'] = (date) => { + return zeroPad(date.millisecond() * 100000, 8); +}; + +formatTokenFunctions['SSSSSSSSS'] = (date) => { + return zeroPad(date.millisecond() * 1000000, 9); +}; + +formatTokenFunctions['x'] = (date) => { + return `${date.valueOf()}`; +}; + +formatTokenFunctions['X'] = (date) => { + return `${date.unix()}`; +}; + +formatTokenFunctions['z'] = (date) => { + return parseZoneInfo({ + ts: date.valueOf(), + locale: date.locale(), + timeZone: date.timeZone(), + offsetFormat: 'short', + }); +}; + +formatTokenFunctions['zz'] = (date) => { + return parseZoneInfo({ + ts: date.valueOf(), + locale: date.locale(), + timeZone: date.timeZone(), + offsetFormat: 'long', + }); +}; + +formatTokenFunctions['DDD'] = (date) => { + return `${date.dayOfYear()}`; +}; + +formatTokenFunctions['DDDD'] = (date) => { + return zeroPad(date.dayOfYear(), 3); +}; + +formatTokenFunctions['DDDo'] = (date, locale) => { + return `${locale.ordinal?.(date.dayOfYear(), 'DDD')}`; +}; + +function getShort({ + date, + format, + data, + index, + fullData, + maxLength, +}: { + date: DateTime; + format: string; + data?: string[] | ((date: DateTime, format: string) => string); + index: number; + fullData?: string[] | ((date: DateTime, format: string) => string); + maxLength?: number; +}) { + let value = ''; + if (data) { + value = typeof data === 'function' ? data(date, format) : data[index]; + } + + if (!value && fullData) { + value = typeof fullData === 'function' ? fullData(date, format) : fullData[index]; + if (value) { + value = value.slice(0, maxLength); + } + } + + if (value) { + return value; + } + + throw new Error('Invalid locale data'); +} + +function zeroPad(number: number, targetLength: number, forceSign = false) { + const absNumber = String(Math.abs(number)); + let sign = ''; + if (number < 0) { + sign = '-'; + } else if (forceSign) { + sign = '+'; + } + + return `${sign}${absNumber.padStart(targetLength, '0')}`; +} diff --git a/src/dateTime/index.ts b/src/dateTime/index.ts index 777af88..a827e6b 100644 --- a/src/dateTime/index.ts +++ b/src/dateTime/index.ts @@ -1 +1,2 @@ export * from './dateTime'; +export * from './format'; diff --git a/src/dateTime/parse.ts b/src/dateTime/parse.ts new file mode 100644 index 0000000..1174dc0 --- /dev/null +++ b/src/dateTime/parse.ts @@ -0,0 +1,64 @@ +import type {InputObject} from '../typings'; +import {normalizeComponent, normalizeDateComponents} from '../utils'; + +export function getTimestampFromArray(input: (number | string)[], utc = false) { + if (input.length === 0) { + return Date.now(); + } + + const dateParts = input.map(Number); + let date: Date; + const [year, month = 0, day = 1, hours = 0, minutes = 0, seconds = 0, milliseconds = 0] = + dateParts; + if (utc) { + date = new Date(Date.UTC(year, month, day, hours, minutes, seconds, milliseconds)); + } else { + date = new Date(year, month, day, hours, minutes, seconds, milliseconds); + } + + if (year >= 0 && year < 100) { + if (utc) { + date.setUTCFullYear(year, month, day); + } else { + date.setFullYear(year, month, day); + } + } + + return date.valueOf(); +} + +export function getTimestampFromObject(input: InputObject, utc = false) { + if (Object.keys(input).length === 0) { + return Date.now(); + } + const normalized = normalizeDateComponents(input, normalizeComponent); + normalized.day = normalized.day ?? normalized.date; + const hasYear = normalized.year !== undefined; + const hasMonth = normalized.month !== undefined; + const hasDate = normalized.date !== undefined; + + const now = new Date(Date.now()); + const year = normalized.year ?? utc ? now.getUTCFullYear() : now.getFullYear(); + let month = normalized.month; + if (month === undefined) { + if (!hasYear && !hasDate) { + month = utc ? now.getUTCMonth() : now.getMonth(); + } else { + month = 0; + } + } + let day = normalized.day; + if (day === undefined) { + if (!hasYear && !hasMonth) { + day = utc ? now.getUTCDate() : now.getDate(); + } else { + day = 1; + } + } + const hours = normalized.hour ?? 0; + const minutes = normalized.minute ?? 0; + const seconds = normalized.second ?? 0; + const milliseconds = normalized.millisecond ?? 0; + + return getTimestampFromArray([year, month, day, hours, minutes, seconds, milliseconds], utc); +} diff --git a/src/dateTime/relative.ts b/src/dateTime/relative.ts new file mode 100644 index 0000000..9a97510 --- /dev/null +++ b/src/dateTime/relative.ts @@ -0,0 +1,76 @@ +import type {Locale, RelativeTime} from '../settings/types'; +import type {BaseUnit, DateTime, DateTimeInput} from '../typings'; + +import {dateTime} from './dateTime'; + +export interface RelativeTimeThreshold { + l: Exclude; + r: number; + d?: BaseUnit; +} + +const thresholds: RelativeTimeThreshold[] = [ + {l: 's', r: 44, d: 'second'}, + {l: 'm', r: 89}, + {l: 'mm', r: 44, d: 'minute'}, + {l: 'h', r: 89}, + {l: 'hh', r: 21, d: 'hour'}, + {l: 'd', r: 35}, + {l: 'dd', r: 25, d: 'day'}, + {l: 'M', r: 45}, + {l: 'MM', r: 10, d: 'month'}, + {l: 'y', r: 17}, + {l: 'yy', r: Infinity, d: 'year'}, +]; + +const relObj = { + future: 'in %s', + past: '%s ago', + s: 'a few seconds', + m: 'a minute', + mm: '%d minutes', + h: 'an hour', + hh: '%d hours', + d: 'a day', + dd: '%d days', + M: 'a month', + MM: '%d months', + y: 'a year', + yy: '%d years', +} satisfies RelativeTime; + +export function fromTo( + date: DateTime, + input: DateTimeInput, + loc: Locale['relativeTime'] = relObj, + withoutSuffix = false, + isFrom = true, +): string { + let result = 0; + let isFuture; + let out = ''; + for (let i = 0; i < thresholds.length; i += 1) { + let t = thresholds[i]; + if (t.d) { + result = isFrom ? date.diff(input, t.d, true) : dateTime({input}).diff(date, t.d, true); + } + const abs = Math.round(Math.abs(result)); + isFuture = result > 0; + if (abs <= t.r) { + if (abs <= 1 && i > 0) t = thresholds[i - 1]; // 1 minutes -> a minute, 0 seconds -> 0 second + const format = loc[t.l]; + if (typeof format === 'string') { + out = format.replace('%d', `${abs}`); + } else { + out = format(abs, withoutSuffix, t.l, isFuture); + } + break; + } + } + if (withoutSuffix) return out; + const pastOrFuture = isFuture ? loc.future : loc.past; + if (typeof pastOrFuture === 'function') { + return pastOrFuture(out); + } + return pastOrFuture.replace('%s', out); +} diff --git a/src/dayjs/dayjs.d.ts b/src/dayjs/dayjs.d.ts deleted file mode 100644 index 6219890..0000000 --- a/src/dayjs/dayjs.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -import 'dayjs'; - -declare module 'dayjs' { - interface ConfigTypeMap { - // to fix inconsistent typing between dayjs and DateTime - // dayjs expects [number?, number?, number?, number?, number?, number?, number?] - // but DateTime allows Array - dateTimeArray: Array; - } -} diff --git a/src/dayjs/index.ts b/src/dayjs/index.ts index 959d503..f416cd2 100644 --- a/src/dayjs/index.ts +++ b/src/dayjs/index.ts @@ -1,46 +1,13 @@ import dayjs from 'dayjs'; -import advancedFormat from 'dayjs/plugin/advancedFormat'; -import arraySupport from 'dayjs/plugin/arraySupport'; import customParseFormat from 'dayjs/plugin/customParseFormat'; -import isoWeek from 'dayjs/plugin/isoWeek'; -import localizedFormat from 'dayjs/plugin/localizedFormat'; -import objectSupport from 'dayjs/plugin/objectSupport'; -import quarterOfYear from 'dayjs/plugin/quarterOfYear'; -import relativeTime from 'dayjs/plugin/relativeTime'; -import timezone from 'dayjs/plugin/timezone'; import updateLocale from 'dayjs/plugin/updateLocale'; import utc from 'dayjs/plugin/utc'; -import weekOfYear from 'dayjs/plugin/weekOfYear'; -dayjs.extend(arraySupport); dayjs.extend(customParseFormat); -dayjs.extend(weekOfYear); -dayjs.extend(isoWeek); -dayjs.extend(quarterOfYear); -dayjs.extend(relativeTime); -dayjs.extend(localizedFormat); -// advancedFormat must be after localizedFormat -dayjs.extend(advancedFormat); -// utc must be after localizedFormat and advancedFormat dayjs.extend(utc); -dayjs.extend(timezone); dayjs.extend(updateLocale); -// the modifications made in objectSupport would preserve other plugins behavior -// but not vice versa, therefore it should come last -dayjs.extend(objectSupport); export default dayjs; export type {ConfigTypeMap, ConfigType} from 'dayjs'; -export type { - arraySupport, - customParseFormat, - isoWeek, - quarterOfYear, - relativeTime, - timezone, - utc, - localizedFormat, - updateLocale, - objectSupport, -}; +export type {customParseFormat, utc, updateLocale}; diff --git a/src/duration/__tests__/format.ts b/src/duration/__tests__/format.ts index a33838b..6a635cd 100644 --- a/src/duration/__tests__/format.ts +++ b/src/duration/__tests__/format.ts @@ -25,7 +25,7 @@ test('Duration#toISOString fills out every field', () => { }); test('Duration#toISOString fills out every field with fractional', () => { - const dur = duration({ + const durFrac = duration({ years: 1.1, months: 2.2, weeks: 1.1, @@ -35,7 +35,7 @@ test('Duration#toISOString fills out every field with fractional', () => { seconds: 6.6, milliseconds: 7, }); - expect(dur.toISOString()).toBe('P1.1Y2.2M1.1W3.3DT4.4H5.5M6.607S'); + expect(durFrac.toISOString()).toBe('P1.1Y2.2M1.1W3.3DT4.4H5.5M6.607S'); }); test('Duration#toISOString creates a minimal string', () => { diff --git a/src/duration/__tests__/parse.ts b/src/duration/__tests__/parse.ts index f30e0ea..b7d1885 100644 --- a/src/duration/__tests__/parse.ts +++ b/src/duration/__tests__/parse.ts @@ -3,7 +3,7 @@ import {duration} from '..'; -const check = (s: any, ob: any) => { +const check = (s: string, ob: unknown) => { expect(duration(s).toObject()).toEqual(ob); }; diff --git a/src/index.ts b/src/index.ts index 9a3d09d..bf4d9dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ import type {PublicSettings} from './settings/types'; export const settings = innerSettings as PublicSettings; -export {dateTime, dateTimeUtc, isDateTime} from './dateTime'; +export {dateTime, dateTimeUtc, isDateTime, expandFormat} from './dateTime'; export {parse as defaultRelativeParse, isLikeRelative as defaultIsLikeRelative} from './datemath'; export {dateTimeParse, isValid, isLikeRelative} from './parser'; export {getTimeZonesList, guessUserTimeZone, isValidTimeZone, timeZoneOffset} from './timeZone'; diff --git a/src/settings/settings.ts b/src/settings/settings.ts index a4daa1e..41234fc 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -5,7 +5,7 @@ import dayjs from '../dayjs'; import {normalizeTimeZone} from '../timeZone'; import {localeLoaders} from './locales'; -import type {Parser, PublicSettings, UpdateLocaleConfig} from './types'; +import type {Locale, Parser, PublicSettings, UpdateLocaleConfig} from './types'; class Settings implements PublicSettings { // 'en' - preloaded locale in dayjs @@ -40,7 +40,7 @@ class Settings implements PublicSettings { return this.defaultLocale; } - getLocaleData() { + getLocaleData(): Locale { const locales = dayjs.Ls; let localeObject = locales[this.getLocale()]; @@ -52,7 +52,7 @@ class Settings implements PublicSettings { throw new Error('There is something really wrong happening. Locale data is absent.'); } - return cloneDeep(localeObject); + return cloneDeep(localeObject) as Locale; } setLocale(locale: string) { diff --git a/src/settings/types.ts b/src/settings/types.ts index d961156..5fe9e67 100644 --- a/src/settings/types.ts +++ b/src/settings/types.ts @@ -2,8 +2,55 @@ import type {ParseOptions} from '../datemath'; import type dayjs from '../dayjs'; import type {DateTime} from '../typings'; +export interface LongDateFormat { + L: string; + LL: string; + LLL: string; + LLLL: string; + LT: string; + LTS: string; + + l?: string; + ll?: string; + lll?: string; + llll?: string; + lt?: string; + lts?: string; +} + +type RelativeFormatFunc = ( + v: number, + withoutSuffix: boolean, + unit: Exclude, + isFuture: boolean, +) => string; +export interface RelativeTime { + future: string | ((v: string) => string); + past: string | ((v: string) => string); + s: string | RelativeFormatFunc; + m: string | RelativeFormatFunc; + mm: string | RelativeFormatFunc; + h: string | RelativeFormatFunc; + hh: string | RelativeFormatFunc; + d: string | RelativeFormatFunc; + dd: string | RelativeFormatFunc; + M: string | RelativeFormatFunc; + MM: string | RelativeFormatFunc; + y: string | RelativeFormatFunc; + yy: string | RelativeFormatFunc; +} + +export interface Locale extends Omit { + yearStart?: number; + meridiem?: (hour: number, minute: number, isLowercase: boolean) => string; + ordinal?: (n: number, unit: string) => number | string; + invalidDate?: string; + formats?: LongDateFormat; + relativeTime?: RelativeTime; +} + // https://dayjs.gitee.io/docs/ru/customization/customization -export type UpdateLocaleConfig = Parameters[1] | Partial; +export type UpdateLocaleConfig = Parameters[1] | Partial; export interface Parser { parse: (text: string, options?: ParseOptions) => DateTime | undefined; @@ -15,7 +62,7 @@ export interface PublicSettings { getLocale(): string; - getLocaleData(): ILocale; + getLocaleData(): Locale; setLocale(locale: string): void; diff --git a/src/timeZone/timeZone.ts b/src/timeZone/timeZone.ts index dfd17a0..f98f934 100644 --- a/src/timeZone/timeZone.ts +++ b/src/timeZone/timeZone.ts @@ -1,12 +1,12 @@ import {UtcTimeZone} from '../constants'; -import dayjs from '../dayjs'; import type {TimeZone} from '../typings'; import {getDateTimeFormat} from '../utils/locale'; /** * Returns the user's time zone. */ -export const guessUserTimeZone = () => dayjs.tz.guess(); +// eslint-disable-next-line new-cap +export const guessUserTimeZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone; /** * Returns all time zones. @@ -163,3 +163,36 @@ export function fixOffset( // If it's different, we're in a hole time. The offset has changed, but we don't adjust the time return [localTS - Math.min(o2, o3) * 60 * 1000, Math.min(o2, o3)]; } + +export function parseZoneInfo({ + timeZone, + ts, + locale, + offsetFormat, +}: { + timeZone?: string; + ts: number; + locale: string; + offsetFormat?: 'short' | 'long'; +}) { + const date = new Date(ts); + const intlOpts: Intl.DateTimeFormatOptions = { + hourCycle: 'h23', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }; + + if (timeZone) { + intlOpts.timeZone = normalizeTimeZone(timeZone, timeZone); + } + + const modified = {timeZoneName: offsetFormat, ...intlOpts}; + + const parsed = new Intl.DateTimeFormat(locale, modified) + .formatToParts(date) + .find((m) => m.type.toLowerCase() === 'timezonename'); + return parsed ? parsed.value : ''; +} diff --git a/src/typings/dateTime.ts b/src/typings/dateTime.ts index 299fc31..1a8fcbf 100644 --- a/src/typings/dateTime.ts +++ b/src/typings/dateTime.ts @@ -44,29 +44,20 @@ export type AllUnit = | DateUnit | WeekUnit | IsoWeekUnit + | 'weekday' + | 'weekdays' + | 'e' | 'isoWeekday' | 'isoWeekdays' - | 'E'; + | 'E' + | 'dayOfYear' + | 'dayOfYears' + | 'DDD'; export type InputObject = Partial>; -export type SetObject = Partial< - Record< - | BaseUnit - | QuarterUnit - | DateUnit - | WeekUnit - | IsoWeekUnit - | 'weekday' - | 'weekdays' - | 'e' - | 'isoWeekday' - | 'isoWeekdays' - | 'E', - number | string - > ->; +export type SetObject = Partial>; -export interface DateTime extends Object { +export interface DateTime { add(amount: DurationInput, unit?: DurationUnit): DateTime; subtract(amount: DurationInput, unit?: DurationUnit): DateTime; set(unit: AllUnit, amount: number): DateTime; @@ -93,23 +84,40 @@ export interface DateTime extends Object { utcOffset(offset: number | string, keepLocalTime?: boolean): DateTime; timeZone(): string; timeZone(timeZone: string, keepLocalTime?: boolean): DateTime; + /** Get the number of days in the current month. */ daysInMonth: () => number; - date(): number; - date(value: number): DateTime; + /** Gets the day of the week, with Sunday as 0 and Saturday as 6. */ + day(): number; + /** Sets the day of the week, with Sunday as 0 and Saturday as 6. */ + day(value: number): DateTime; + /** Gets the ISO day of the week with 1 being Monday and 7 being Sunday. */ + isoWeekday(): number; + /** Sets the ISO day of the week with 1 being Monday and 7 being Sunday. */ + isoWeekday(value: number): DateTime; + /** Gets the day of the week according to the locale. 0 being first day of the week and 6 being last. */ + weekday(): number; + /** Sets the day of the week according to the locale. 0 being first day of the week and 6 being last. */ + weekday(value: number): number; + /** Gets the week of the year according to the locale. */ week(): number; + /** Sets the week of the year according to the locale. */ week(value: number): DateTime; + /** Gets the ISO week of the year. First week is the week with the first Thursday of the year (i.e. of January) in it.*/ isoWeek(): number; + /** Sets the ISO week of the year. */ isoWeek(value: number): DateTime; - isoWeekday(): number; - isoWeekday(value: number): DateTime; + /** Gets the day of the year. */ + dayOfYear(): number; + /** Sets the day of the year. */ + dayOfYear(value: number): DateTime; month(): number; month(value: number): DateTime; quarter(): number; quarter(value: number): DateTime; year(): number; year(value: number): DateTime; - day(): number; - day(value: number): DateTime; + date(): number; + date(value: number): DateTime; hour(): number; hour(value: number): DateTime; minute(): number; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 3a02d0f..639209c 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,3 +1,5 @@ +import type {DateTime} from '../typings'; + export type CompareStringsOptions = { ignoreCase?: boolean; }; @@ -11,6 +13,10 @@ export function isLeapYear(year: number) { return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); } +export function daysInYear(year: number) { + return isLeapYear(year) ? 366 : 365; +} + export function daysInMonth(year: number, month: number): number { const modMonth = floorMod(month, 12), modYear = year + (month - modMonth) / 12; @@ -22,7 +28,17 @@ export function daysInMonth(year: number, month: number): number { } } -export function tsToObject(ts: number, offset: number) { +export interface DateObject { + year: number; + month: number; + date: number; + hour: number; + minute: number; + second: number; + millisecond: number; +} + +export function tsToObject(ts: number, offset: number): DateObject { const value = ts + offset * 60 * 1000; const date = new Date(value); @@ -37,7 +53,7 @@ export function tsToObject(ts: number, offset: number) { millisecond: date.getUTCMilliseconds(), }; } -export function objToTS(obj: Record, number>) { +export function objToTS(obj: DateObject) { const ts = Date.UTC( obj.year, obj.month, @@ -141,13 +157,16 @@ const normalizedUnits = { E: 'isoWeekday', isoweekday: 'isoWeekday', isoweekdays: 'isoWeekday', - weekday: 'day', - weekdays: 'day', - e: 'day', + weekday: 'weekday', + weekdays: 'weekday', + e: 'weekday', + dayOfYear: 'dayOfYear', + dayOfYears: 'dayOfYear', + DDD: 'dayOfYear', } as const; export function normalizeComponent(component: string) { - const unit = ['d', 'D', 'm', 'M', 'w', 'W', 'E', 'Q'].includes(component) + const unit = ['d', 'D', 'm', 'M', 'w', 'W', 'e', 'E', 'Q'].includes(component) ? component : component.toLowerCase(); if (unit in normalizedUnits) { @@ -198,3 +217,163 @@ export function offsetFromString(value: string | undefined) { return sign === '+' ? minutes : -minutes; } + +function dayOfWeek(year: number, month: number, day: number) { + const d = new Date(Date.UTC(year, month, day)); + + if (year < 100 && year >= 0) { + d.setUTCFullYear(d.getUTCFullYear() - 1900); + } + + return d.getUTCDay(); +} + +function isoDayOfWeek(year: number, month: number, day: number) { + const d = dayOfWeek(year, month, day); + return d === 0 ? 7 : d; +} + +const nonLeapLadder = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]; +const leapLadder = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335]; + +export function computeOrdinal({year, month, date}: {year: number; month: number; date: number}) { + return date + (isLeapYear(year) ? leapLadder : nonLeapLadder)[month]; +} + +export function uncomputeOrdinal({year, ordinal}: {year: number; ordinal: number}) { + const table = isLeapYear(year) ? leapLadder : nonLeapLadder, + month = table.findIndex((i) => i < ordinal), + day = ordinal - table[month]; + return {month, date: day}; +} + +export function isoWeekdayToLocal(isoWeekday: number, startOfWeek: number) { + return ((isoWeekday - startOfWeek + 7) % 7) + 1; +} + +export function gregorianToWeek( + dateObj: DateObject, + minDaysInFirstWeek: number, + startOfWeek: number, +) { + const {year, month, date} = dateObj; + const day = dayOfWeek(year, month, date); + + const {weekYear, weekNumber, weekday} = gregorianToWeekLocale( + dateObj, + minDaysInFirstWeek, + startOfWeek, + ); + + const { + weekYear: isoWeekYear, + weekNumber: isoWeekNumber, + weekday: isoWeekday, + } = gregorianToWeekLocale(dateObj, 4, 1); + + return { + day, + weekYear, + weekNumber, + weekday: weekday - 1, + isoWeekYear, + isoWeekNumber, + isoWeekday, + }; +} + +function gregorianToWeekLocale( + dateObj: DateObject, + minDaysInFirstWeek: number, + startOfWeek: number, +) { + const {year, month, date} = dateObj; + const isoWeekday = isoDayOfWeek(year, month, date); + const weekday = isoWeekdayToLocal(isoWeekday, startOfWeek); + + const ordinal = computeOrdinal({year, month, date}); + let weekNumber = Math.floor((ordinal - weekday + 14 - minDaysInFirstWeek) / 7); + let weekYear: number; + + if (weekNumber < 1) { + weekYear = year - 1; + weekNumber = weeksInWeekYear(weekYear, minDaysInFirstWeek, startOfWeek); + } else if (weekNumber > weeksInWeekYear(year, minDaysInFirstWeek, startOfWeek)) { + weekYear = year + 1; + weekNumber = 1; + } else { + weekYear = year; + } + + return {weekYear, weekNumber, weekday}; +} + +function firstWeekOffset(year: number, minDaysInFirstWeek: number, startOfWeek: number) { + const fwdlw = isoWeekdayToLocal(isoDayOfWeek(year, 0, minDaysInFirstWeek), startOfWeek); + return -fwdlw + minDaysInFirstWeek - 1; +} + +export function weeksInWeekYear(weekYear: number, minDaysInFirstWeek = 4, startOfWeek = 1) { + const weekOffset = firstWeekOffset(weekYear, minDaysInFirstWeek, startOfWeek); + const weekOffsetNext = firstWeekOffset(weekYear + 1, minDaysInFirstWeek, startOfWeek); + return (daysInYear(weekYear) - weekOffset + weekOffsetNext) / 7; +} + +export function weekToGregorian( + weekData: { + weekYear: number; + weekNumber: number; + weekday: number; + }, + minDaysInFirstWeek: number, + startOfWeek: number, +) { + const {weekYear, weekNumber, weekday} = weekData; + const weekdayOfJan4 = isoWeekdayToLocal( + isoDayOfWeek(weekYear, 0, minDaysInFirstWeek), + startOfWeek, + ); + const yearInDays = daysInYear(weekYear); + + let ordinal = weekNumber * 7 + weekday - weekdayOfJan4 - 7 + minDaysInFirstWeek; + let year: number; + + if (ordinal < 1) { + year = weekYear - 1; + ordinal += daysInYear(year); + } else if (ordinal > yearInDays) { + year = weekYear + 1; + ordinal -= daysInYear(weekYear); + } else { + year = weekYear; + } + + const {month, date} = uncomputeOrdinal({year, ordinal}); + return {year, month, date}; +} + +export function monthDiff(a: DateTime, b: DateTime): number { + if (a.date() < b.date()) { + // end-of-month calculations work correct when the start month has more + // days than the end month. + return -monthDiff(b, a); + } + // difference in months + const wholeMonthDiff = (b.year() - a.year()) * 12 + (b.month() - a.month()); + // b is in (anchor - 1 month, anchor + 1 month) + const anchor = a.add(wholeMonthDiff, 'months'); + let adjust: number; + + if (b.valueOf() - anchor.valueOf() < 0) { + const anchor2 = a.add(wholeMonthDiff - 1, 'months'); + // linear across the month + adjust = (b.valueOf() - anchor.valueOf()) / (anchor.valueOf() - anchor2.valueOf()); + } else { + const anchor2 = a.add(wholeMonthDiff + 1, 'months'); + // linear across the month + adjust = (b.valueOf() - anchor.valueOf()) / (anchor2.valueOf() - anchor.valueOf()); + } + + //check for negative zero, return zero if negative zero + return -(wholeMonthDiff + adjust) || 0; +}