diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..378eac2 --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +build diff --git a/package-lock.json b/package-lock.json index 0950a88..5326794 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2776,9 +2776,9 @@ } }, "dayjs": { - "version": "1.11.7", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz", - "integrity": "sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ==" + "version": "1.11.10", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", + "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==" }, "debug": { "version": "4.3.4", diff --git a/package.json b/package.json index 9825c88..cfff6b9 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "license": "MIT", "scripts": { - "build": "rm -rf build && tsc", + "build": "rm -rf build && tsc -p tsconfig.publish.json", "lint": "eslint \"src/**/*\" --quiet", "test": "jest", "test:watch": "jest --watchAll", @@ -41,7 +41,7 @@ "typescript": "^4.9.4" }, "dependencies": { - "dayjs": "^1.11.7", + "dayjs": "1.11.10", "lodash": "^4.17.0" }, "nano-staged": { diff --git a/src/constants/index.ts b/src/constants/index.ts index d8ce1e9..b1d2936 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1,4 +1,3 @@ export * from './dateTime'; export * from './format'; export * from './timeZone'; -export * from './utils'; diff --git a/src/constants/utils.ts b/src/constants/utils.ts deleted file mode 100644 index d55a2ae..0000000 --- a/src/constants/utils.ts +++ /dev/null @@ -1,7 +0,0 @@ -// https://developer.mozilla.org/ru/docs/Web/JavaScript/Reference/Global_Objects/Intl/Collator -export const CollatorSensitivity = { - ACCENT: 'accent', - BASE: 'base', - CASE: 'case', - VARIANT: 'variant', -}; diff --git a/src/dateTime/__tests__/addSubtract.ts b/src/dateTime/__tests__/addSubtract.ts new file mode 100644 index 0000000..00c81e0 --- /dev/null +++ b/src/dateTime/__tests__/addSubtract.ts @@ -0,0 +1,246 @@ +import {dateTime} from '../dateTime'; + +test('add short reverse args', () => { + let a = dateTime().year(2011).month(9).date(12).hour(6).minute(7).second(8).millisecond(500); + + expect((a = a.add({ms: 50})).millisecond()).toBe(550); + expect((a = a.add({s: 1})).second()).toBe(9); + expect((a = a.add({m: 1})).minute()).toBe(8); + expect((a = a.add({h: 1})).hour()).toBe(7); + expect((a = a.add({d: 1})).date()).toBe(13); + expect((a = a.add({w: 1})).date()).toBe(20); + expect((a = a.add({M: 1})).month()).toBe(10); + expect((a = a.add({y: 1})).year()).toBe(2012); + expect((a = a.add({Q: 1})).month()).toBe(1); + + const b = dateTime({input: [2010, 0, 31]}).add({M: 1}); + expect(b.month()).toBe(1); + expect(b.date()).toBe(28); + + const c = dateTime({input: [2010, 1, 28]}).subtract({M: 1}); + expect(c.month()).toBe(0); + expect(c.date()).toBe(28); + + const d = dateTime({input: [2010, 1, 28]}).subtract({Q: 1}); + expect(d.month()).toBe(10); + expect(d.date()).toBe(28); + expect(d.year()).toBe(2009); +}); + +test('add long reverse args', () => { + let a = dateTime().year(2011).month(9).date(12).hour(6).minute(7).second(8).millisecond(500); + + expect((a = a.add({milliseconds: 50})).millisecond()).toBe(550); + expect((a = a.add({seconds: 1})).second()).toBe(9); + expect((a = a.add({minutes: 1})).minute()).toBe(8); + expect((a = a.add({hours: 1})).hour()).toBe(7); + expect((a = a.add({days: 1})).date()).toBe(13); + expect((a = a.add({weeks: 1})).date()).toBe(20); + expect((a = a.add({months: 1})).month()).toBe(10); + expect((a = a.add({years: 1})).year()).toBe(2012); + expect((a = a.add({quarters: 1})).month()).toBe(1); +}); + +test('add long singular reverse args', () => { + let a = dateTime().year(2011).month(9).date(12).hour(6).minute(7).second(8).millisecond(500); + + expect((a = a.add({millisecond: 50})).millisecond()).toBe(550); + expect((a = a.add({second: 1})).second()).toBe(9); + expect((a = a.add({minute: 1})).minute()).toBe(8); + expect((a = a.add({hour: 1})).hour()).toBe(7); + expect((a = a.add({day: 1})).date()).toBe(13); + expect((a = a.add({week: 1})).date()).toBe(20); + expect((a = a.add({month: 1})).month()).toBe(10); + expect((a = a.add({year: 1})).year()).toBe(2012); + expect((a = a.add({quarter: 1})).month()).toBe(1); +}); + +test('add string long', () => { + let a = dateTime().year(2011).month(9).date(12).hour(6).minute(7).second(8).millisecond(500); + + expect((a = a.add(50, 'milliseconds')).millisecond()).toBe(550); + expect((a = a.add(1, 'seconds')).second()).toBe(9); + expect((a = a.add(1, 'minutes')).minute()).toBe(8); + expect((a = a.add(1, 'hours')).hour()).toBe(7); + expect((a = a.add(1, 'days')).date()).toBe(13); + expect((a = a.add(1, 'weeks')).date()).toBe(20); + expect((a = a.add(1, 'months')).month()).toBe(10); + expect((a = a.add(1, 'years')).year()).toBe(2012); + expect((a = a.add(1, 'quarters')).month()).toBe(1); +}); + +test('add string long singular', () => { + let a = dateTime().year(2011).month(9).date(12).hour(6).minute(7).second(8).millisecond(500); + + expect((a = a.add(50, 'millisecond')).millisecond()).toBe(550); + expect((a = a.add(1, 'second')).second()).toBe(9); + expect((a = a.add(1, 'minute')).minute()).toBe(8); + expect((a = a.add(1, 'hour')).hour()).toBe(7); + expect((a = a.add(1, 'day')).date()).toBe(13); + expect((a = a.add(1, 'week')).date()).toBe(20); + expect((a = a.add(1, 'month')).month()).toBe(10); + expect((a = a.add(1, 'year')).year()).toBe(2012); + expect((a = a.add(1, 'quarter')).month()).toBe(1); +}); + +test('add string short', () => { + let a = dateTime().year(2011).month(9).date(12).hour(6).minute(7).second(8).millisecond(500); + + expect((a = a.add(50, 'ms')).millisecond()).toBe(550); + expect((a = a.add(1, 's')).second()).toBe(9); + expect((a = a.add(1, 'm')).minute()).toBe(8); + expect((a = a.add(1, 'h')).hour()).toBe(7); + expect((a = a.add(1, 'd')).date()).toBe(13); + expect((a = a.add(1, 'w')).date()).toBe(20); + expect((a = a.add(1, 'M')).month()).toBe(10); + expect((a = a.add(1, 'y')).year()).toBe(2012); + expect((a = a.add(1, 'Q')).month()).toBe(1); +}); + +test('add strings string short', () => { + let a = dateTime().year(2011).month(9).date(12).hour(6).minute(7).second(8).millisecond(500); + + expect((a = a.add('50', 'ms')).millisecond()).toBe(550); + expect((a = a.add('1', 's')).second()).toBe(9); + expect((a = a.add('1', 'm')).minute()).toBe(8); + expect((a = a.add('1', 'h')).hour()).toBe(7); + expect((a = a.add('1', 'd')).date()).toBe(13); + expect((a = a.add('1', 'w')).date()).toBe(20); + expect((a = a.add('1', 'M')).month()).toBe(10); + expect((a = a.add('1', 'y')).year()).toBe(2012); + expect((a = a.add('1', 'Q')).month()).toBe(1); +}); + +test('add no string with milliseconds default', () => { + const a = dateTime().year(2011).month(9).date(12).hour(6).minute(7).second(8).millisecond(500); + + expect(a.add(50).millisecond()).toBe(550); +}); + +test('subtract strings string short', () => { + let a = dateTime().year(2011).month(9).date(12).hour(6).minute(7).second(8).millisecond(500); + + expect((a = a.subtract('50', 'ms')).millisecond()).toBe(450); + expect((a = a.subtract('1', 's')).second()).toBe(7); + expect((a = a.subtract('1', 'm')).minute()).toBe(6); + expect((a = a.subtract('1', 'h')).hour()).toBe(5); + expect((a = a.subtract('1', 'd')).date()).toBe(11); + expect((a = a.subtract('1', 'w')).date()).toBe(4); + expect((a = a.subtract('1', 'M')).month()).toBe(8); + expect((a = a.subtract('1', 'y')).year()).toBe(2010); + expect((a = a.subtract('1', 'Q')).month()).toBe(5); +}); + +test('add across DST', () => { + const a = dateTime({input: '2023-11-05T05:00:00Z', timeZone: 'America/New_York'}); + + expect(a.hour()).toBe(1); + expect(a.utcOffset()).toBe(-4 * 60); + + const b = a.add(24, 'hours'); + expect(b.hour()).toBe(0); // adding hours should respect DST difference + expect(b.utcOffset()).toBe(-5 * 60); + + // adding days over DST difference should result in the same hour + const c = a.add(1, 'day'); + expect(c.hour()).toBe(1); + expect(c.utcOffset()).toBe(-5 * 60); + + // adding months over DST difference should result in the same hour + const d = a.add(1, 'month'); + expect(d.hour()).toBe(1); + expect(d.utcOffset()).toBe(-5 * 60); + + // adding quarters over DST difference should result in the same hour + const e = a.add(1, 'quarter'); + expect(e.hour()).toBe(1); + expect(e.utcOffset()).toBe(-5 * 60); +}); + +test('add decimal values of days and months', () => { + expect( + dateTime({input: [2016, 3, 3]}) + .add(1.5, 'days') + .date(), + ).toBe(5); + expect( + dateTime({input: [2016, 3, 3]}) + .add(-1.5, 'days') + .date(), + ).toBe(1); + expect( + dateTime({input: [2016, 3, 1]}) + .add(-1.5, 'days') + .date(), + ).toBe(30); + expect( + dateTime({input: [2016, 3, 3]}) + .add(1.5, 'months') + .month(), + ).toBe(5); + expect( + dateTime({input: [2016, 3, 3]}) + .add(-1.5, 'months') + .month(), + ).toBe(1); + expect( + dateTime({input: [2016, 0, 3]}) + .add(-1.5, 'months') + .month(), + ).toBe(10); + expect( + dateTime({input: [2016, 3, 3]}) + .subtract(1.5, 'days') + .date(), + ).toBe(1); + expect( + dateTime({input: [2016, 3, 2]}) + .subtract(1.5, 'days') + .date(), + ).toBe(31); + expect( + dateTime({input: [2016, 1, 1]}) + .subtract(1.1, 'days') + .date(), + ).toBe(31); + expect( + dateTime({input: [2016, 3, 3]}) + .subtract(-1.5, 'days') + .date(), + ).toBe(5); + expect( + dateTime({input: [2016, 3, 30]}) + .subtract(-1.5, 'days') + .date(), + ).toBe(2); + expect( + dateTime({input: [2016, 3, 3]}) + .subtract(1.5, 'months') + .month(), + ).toBe(1); + expect( + dateTime({input: [2016, 3, 3]}) + .subtract(-1.5, 'months') + .month(), + ).toBe(5); + expect( + dateTime({input: [2016, 11, 31]}) + .subtract(-1.5, 'months') + .month(), + ).toBe(1); + expect( + dateTime({input: [2016, 0, 1]}) + .add(1.5, 'years') + .format('YYYY-MM-DD'), + ).toBe('2017-07-01'); + expect( + dateTime({input: [2016, 0, 1]}) + .add(1.6, 'years') + .format('YYYY-MM-DD'), + ).toBe('2017-08-01'); + expect( + dateTime({input: [2016, 0, 1]}) + .add(1.1, 'quarters') + .format('YYYY-MM-DD'), + ).toBe('2016-04-01'); +}); diff --git a/src/dateTime/__tests__/offset.ts b/src/dateTime/__tests__/offset.ts new file mode 100644 index 0000000..624ffcc --- /dev/null +++ b/src/dateTime/__tests__/offset.ts @@ -0,0 +1,66 @@ +import {dateTime} from '../dateTime'; + +test('setter / getter', () => { + const m = dateTime({input: [2010]}); + + expect(m.utcOffset(0).utcOffset()).toBe(0); + + expect(m.utcOffset(1).utcOffset()).toBe(60); + expect(m.utcOffset(60).utcOffset()).toBe(60); + expect(m.utcOffset('+01:00').utcOffset()).toBe(60); + expect(m.utcOffset('+0100').utcOffset()).toBe(60); + + expect(m.utcOffset(-1).utcOffset()).toBe(-60); + expect(m.utcOffset(-60).utcOffset()).toBe(-60); + expect(m.utcOffset('-01:00').utcOffset()).toBe(-60); + expect(m.utcOffset('-0100').utcOffset()).toBe(-60); + + expect(m.utcOffset(1.5).utcOffset()).toBe(90); + expect(m.utcOffset(90).utcOffset()).toBe(90); + expect(m.utcOffset('+01:30').utcOffset()).toBe(90); + expect(m.utcOffset('+0130').utcOffset()).toBe(90); + + expect(m.utcOffset(-1.5).utcOffset()).toBe(-90); + expect(m.utcOffset(-90).utcOffset()).toBe(-90); + expect(m.utcOffset('-01:30').utcOffset()).toBe(-90); + expect(m.utcOffset('-0130').utcOffset()).toBe(-90); + + expect(m.utcOffset('+00:10').utcOffset()).toBe(10); + expect(m.utcOffset('-00:10').utcOffset()).toBe(-10); + expect(m.utcOffset('+0010').utcOffset()).toBe(10); + expect(m.utcOffset('-0010').utcOffset()).toBe(-10); +}); + +test('utcOffset shorthand hours -> minutes', () => { + let i; + for (i = -15; i <= 15; ++i) { + expect(dateTime().utcOffset(i).utcOffset()).toBe(i * 60); + } + + expect(dateTime().utcOffset(-16).utcOffset()).toBe(-16); + expect(dateTime().utcOffset(16).utcOffset()).toBe(16); +}); + +test('change hours when changing the utc offset', () => { + const m = dateTime({input: [2000, 0, 1, 6]}).utc(true); + expect(m.hour()).toBe(6); + + // sanity check + expect(m.utcOffset(0).hour()).toBe(6); + + expect(m.utcOffset(-60).hour()).toBe(5); + + expect(m.utcOffset(60).hour()).toBe(7); +}); + +test('change minutes when changing the utc offset', () => { + const m = dateTime({input: Date.UTC(2000, 0, 1, 6, 31)}); + + expect(m.utcOffset(0).format('HH:mm')).toBe('06:31'); + + expect(m.utcOffset(-30).format('HH:mm')).toBe('06:01'); + + expect(m.utcOffset(30).format('HH:mm')).toBe('07:01'); + + expect(m.utcOffset(-1380).format('HH:mm')).toBe('07:31'); +}); diff --git a/src/dateTime/__tests__/startEndOf.ts b/src/dateTime/__tests__/startEndOf.ts new file mode 100644 index 0000000..18faad4 --- /dev/null +++ b/src/dateTime/__tests__/startEndOf.ts @@ -0,0 +1,317 @@ +import {dateTime} from '../dateTime'; + +test('start of year', () => { + const m = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).startOf('year'); + const ms = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).startOf('years'); + const ma = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).startOf('y'); + + expect(Number(m)).toBe(Number(ms)); // 'Plural or singular should work' + expect(Number(m)).toBe(Number(ma)); // 'Full or abbreviated should work' + expect(m.year()).toBe(2011); // 'keep the year' + expect(m.month()).toBe(0); // 'strip out the month' + expect(m.date()).toBe(1); // 'strip out the day' + expect(m.hour()).toBe(0); // 'strip out the hours' + expect(m.minute()).toBe(0); // 'strip out the minutes' + expect(m.second()).toBe(0); // 'strip out the seconds' + expect(m.millisecond()).toBe(0); // 'strip out the milliseconds' +}); + +test('end of year', () => { + const m = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).endOf('year'); + const ms = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).endOf('years'); + const ma = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).endOf('y'); + + expect(Number(m)).toBe(Number(ms)); // 'Plural or singular should work' + expect(Number(m)).toBe(Number(ma)); // 'Full or abbreviated should work' + expect(m.year()).toBe(2011); // 'keep the year' + expect(m.month()).toBe(11); // 'set the month' + expect(m.date()).toBe(31); // 'set the day' + expect(m.hour()).toBe(23); // 'set the hours' + expect(m.minute()).toBe(59); // 'set the minutes' + expect(m.second()).toBe(59); // 'set the seconds' + expect(m.millisecond()).toBe(999); // 'set the seconds' +}); + +test('start of quarter', () => { + const m = dateTime({input: new Date(2011, 4, 2, 3, 4, 5, 6)}).startOf('quarter'); + const ms = dateTime({input: new Date(2011, 4, 2, 3, 4, 5, 6)}).startOf('quarters'); + const ma = dateTime({input: new Date(2011, 4, 2, 3, 4, 5, 6)}).startOf('Q'); + expect(Number(m)).toBe(Number(ms)); // 'Plural or singular should work' + expect(Number(m)).toBe(Number(ma)); // 'Full or abbreviated should work' + expect(m.year()).toBe(2011); // 'keep the year' + expect(m.quarter()).toBe(2); // 'keep the quarte + expect(m.month()).toBe(3); // 'strip out the month' + expect(m.date()).toBe(1); // 'strip out the day' + expect(m.hour()).toBe(0); // 'strip out the hours' + expect(m.minute()).toBe(0); // 'strip out the minutes' + expect(m.second()).toBe(0); // 'strip out the seconds' + expect(m.millisecond()).toBe(0); // 'strip out the milliseconds' +}); + +test('end of quarter', () => { + const m = dateTime({input: new Date(2011, 4, 2, 3, 4, 5, 6)}).endOf('quarter'); + const ms = dateTime({input: new Date(2011, 4, 2, 3, 4, 5, 6)}).endOf('quarters'); + const ma = dateTime({input: new Date(2011, 4, 2, 3, 4, 5, 6)}).endOf('Q'); + expect(Number(m)).toBe(Number(ms)); // 'Plural or singular should work' + expect(Number(m)).toBe(Number(ma)); // 'Full or abbreviated should work' + expect(m.year()).toBe(2011); // 'keep the year' + expect(m.quarter()).toBe(2); // 'keep the quarter' + expect(m.month()).toBe(5); // 'set the month' + expect(m.date()).toBe(30); // 'set the day' + expect(m.hour()).toBe(23); // 'set the hours' + expect(m.minute()).toBe(59); // 'set the minutes' + expect(m.second()).toBe(59); // 'set the seconds' + expect(m.millisecond()).toBe(999); // 'set the seconds' +}); + +test('start of month', () => { + const m = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).startOf('month'); + const ms = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).startOf('months'); + const ma = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).startOf('M'); + expect(Number(m)).toBe(Number(ms)); // 'Plural or singular should work' + expect(Number(m)).toBe(Number(ma)); // 'Full or abbreviated should work' + expect(m.year()).toBe(2011); // 'keep the year' + expect(m.month()).toBe(1); // 'keep the month' + expect(m.date()).toBe(1); // 'strip out the day' + expect(m.hour()).toBe(0); // 'strip out the hours' + expect(m.minute()).toBe(0); // 'strip out the minutes' + expect(m.second()).toBe(0); // 'strip out the seconds' + expect(m.millisecond()).toBe(0); // 'strip out the milliseconds' +}); + +test('end of month', () => { + const m = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).endOf('month'); + const ms = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).endOf('months'); + const ma = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).endOf('M'); + expect(Number(m)).toBe(Number(ms)); // 'Plural or singular should work') + expect(Number(m)).toBe(Number(ma)); // 'Full or abbreviated should work') + expect(m.year()).toBe(2011); // 'keep the year' + expect(m.month()).toBe(1); // 'keep the month' + expect(m.date()).toBe(28); // 'set the day' + expect(m.hour()).toBe(23); // 'set the hours' + expect(m.minute()).toBe(59); // 'set the minutes' + expect(m.second()).toBe(59); // 'set the seconds' + expect(m.millisecond()).toBe(999); // 'set the seconds' +}); + +test('start of week', () => { + const m = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).startOf('week'); + const ms = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).startOf('weeks'); + const ma = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).startOf('w'); + expect(Number(m)).toBe(Number(ms)); // 'Plural or singular should work') + expect(Number(m)).toBe(Number(ma)); // 'Full or abbreviated should work') + expect(m.year()).toBe(2011); // 'keep the year' + expect(m.month()).toBe(0); // 'rolls back to January' + expect(m.day()).toBe(1); // 'set day of week' + expect(m.date()).toBe(31); // 'set correct date' + expect(m.hour()).toBe(0); // 'strip out the hours' + expect(m.minute()).toBe(0); // 'strip out the minutes' + expect(m.second()).toBe(0); // 'strip out the seconds' + expect(m.millisecond()).toBe(0); // 'strip out the milliseconds' +}); + +test('end of week', () => { + const m = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).endOf('week'); + const ms = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).endOf('weeks'); + const ma = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).endOf('w'); + expect(Number(m)).toBe(Number(ms)); // 'Plural or singular should work' + expect(Number(m)).toBe(Number(ma)); // 'Full or abbreviated should work' + expect(m.year()).toBe(2011); // 'keep the year' + expect(m.month()).toBe(1); // 'keep the month' + expect(m.day()).toBe(0); // 'set the day of the week' + expect(m.date()).toBe(6); // 'set the day' + expect(m.hour()).toBe(23); // 'set the hours' + expect(m.minute()).toBe(59); // 'set the minutes' + expect(m.second()).toBe(59); // 'set the seconds' + expect(m.millisecond()).toBe(999); // 'set the seconds' +}); + +test('start of iso-week', () => { + const m = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).startOf('isoWeek'); + const ms = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).startOf('isoWeeks'); + // const ma = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).startOf('W'); + expect(Number(m)).toBe(Number(ms)); // 'Plural or singular should work' + // expect(Number(m)).toBe(Number(ma)); // 'Full or abbreviated should work' + expect(m.year()).toBe(2011); // 'keep the year' + expect(m.month()).toBe(0); // 'rollback to January' + expect(m.isoWeekday()).toBe(1); // 'set day of iso-week' + expect(m.date()).toBe(31); // 'set correct date' + expect(m.hour()).toBe(0); // 'strip out the hours' + expect(m.minute()).toBe(0); // 'strip out the minutes' + expect(m.second()).toBe(0); // 'strip out the seconds' + expect(m.millisecond()).toBe(0); // 'strip out the milliseconds' +}); + +test('end of iso-week', () => { + const m = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).endOf('isoWeek'); + const ms = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).endOf('isoWeeks'); + // const ma = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).endOf('W'); + expect(Number(m)).toBe(Number(ms)); // 'Plural or singular should work' + // expect(Number(m)).toBe(Number(ma)); // 'Full or abbreviated should work' + expect(m.year()).toBe(2011); // 'keep the year' + expect(m.month()).toBe(1); // 'keep the month' + expect(m.isoWeekday()).toBe(7); // 'set the day of the week' + expect(m.date()).toBe(6); // 'set the day' + expect(m.hour()).toBe(23); // 'set the hours' + expect(m.minute()).toBe(59); // 'set the minutes' + expect(m.second()).toBe(59); // 'set the seconds' + expect(m.millisecond()).toBe(999); // 'set the seconds' +}); + +test('start of day', () => { + const m = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).startOf('day'); + const ms = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).startOf('days'); + const ma = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).startOf('d'); + expect(Number(m)).toBe(Number(ms)); // 'Plural or singular should work' + expect(Number(m)).toBe(Number(ma)); // 'Full or abbreviated should work' + expect(m.year()).toBe(2011); // 'keep the year' + expect(m.month()).toBe(1); // 'keep the month' + expect(m.date()).toBe(2); // 'keep the day' + expect(m.hour()).toBe(0); // 'strip out the hours' + expect(m.minute()).toBe(0); // 'strip out the minutes' + expect(m.second()).toBe(0); // 'strip out the seconds' + expect(m.millisecond()).toBe(0); // 'strip out the milliseconds' +}); + +test('end of day', () => { + const m = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).endOf('day'); + const ms = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).endOf('days'); + const ma = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).endOf('d'); + expect(Number(m)).toBe(Number(ms)); // 'Plural or singular should work' + expect(Number(m)).toBe(Number(ma)); // 'Full or abbreviated should work' + expect(m.year()).toBe(2011); // 'keep the year' + expect(m.month()).toBe(1); // 'keep the month' + expect(m.date()).toBe(2); // 'keep the day' + expect(m.hour()).toBe(23); // 'set the hours' + expect(m.minute()).toBe(59); // 'set the minutes' + expect(m.second()).toBe(59); // 'set the seconds' + expect(m.millisecond()).toBe(999); // 'set the seconds' +}); + +test('start of date', () => { + const m = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).startOf('date'); + const ms = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).startOf('dates'); + const ma = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).startOf('D'); + + expect(Number(m)).toBe(Number(ms)); // 'Plural or singular should work' + expect(Number(m)).toBe(Number(ma)); // 'Full or abbreviated should work' + expect(m.year()).toBe(2011); // 'keep the year' + expect(m.month()).toBe(1); // 'keep the month' + expect(m.date()).toBe(2); // 'keep the day' + expect(m.hour()).toBe(0); // 'strip out the hours' + expect(m.minute()).toBe(0); // 'strip out the minutes' + expect(m.second()).toBe(0); // 'strip out the seconds' + expect(m.millisecond()).toBe(0); // 'strip out the milliseconds' +}); + +test('end of date', () => { + const m = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).endOf('date'); + const ms = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).endOf('dates'); + const ma = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).endOf('D'); + + expect(Number(m)).toBe(Number(ms)); // 'Plural or singular should work' + expect(Number(m)).toBe(Number(ma)); // 'Full or abbreviated should work' + expect(m.year()).toBe(2011); // 'keep the year' + expect(m.month()).toBe(1); // 'keep the month' + expect(m.date()).toBe(2); // 'keep the day' + expect(m.hour()).toBe(23); // 'set the hours' + expect(m.minute()).toBe(59); // 'set the minutes' + expect(m.second()).toBe(59); // 'set the seconds' + expect(m.millisecond()).toBe(999); // 'set the milliseconds' +}); + +test('start of hour', () => { + const m = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).startOf('hour'); + const ms = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).startOf('hours'); + const ma = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).startOf('h'); + + expect(Number(m)).toBe(Number(ms)); // 'Plural or singular should work' + expect(Number(m)).toBe(Number(ma)); // 'Full or abbreviated should work' + expect(m.year()).toBe(2011); // 'keep the year' + expect(m.month()).toBe(1); // 'keep the month' + expect(m.date()).toBe(2); // 'keep the day' + expect(m.hour()).toBe(3); // 'keep the hours' + expect(m.minute()).toBe(0); // 'strip out the minutes' + expect(m.second()).toBe(0); // 'strip out the seconds' + expect(m.millisecond()).toBe(0); // 'strip out the milliseconds' +}); + +test('end of hour', () => { + const m = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).endOf('hour'); + const ms = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).endOf('hours'); + const ma = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).endOf('h'); + + expect(Number(m)).toBe(Number(ms)); // 'Plural or singular should work' + expect(Number(m)).toBe(Number(ma)); // 'Full or abbreviated should work' + expect(m.year()).toBe(2011); // 'keep the year' + expect(m.month()).toBe(1); // 'keep the month' + expect(m.date()).toBe(2); // 'keep the day' + expect(m.hour()).toBe(3); // 'keep the hours' + expect(m.minute()).toBe(59); // 'set the minutes' + expect(m.second()).toBe(59); // 'set the seconds' + expect(m.millisecond()).toBe(999); // 'set the milliseconds' +}); + +test('start of minute', () => { + const m = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).startOf('minute'); + const ms = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).startOf('minutes'); + const ma = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).startOf('m'); + + expect(Number(m)).toBe(Number(ms)); // 'Plural or singular should work' + expect(Number(m)).toBe(Number(ma)); // 'Full or abbreviated should work' + expect(m.year()).toBe(2011); // 'keep the year' + expect(m.month()).toBe(1); // 'keep the month' + expect(m.date()).toBe(2); // 'keep the day' + expect(m.hour()).toBe(3); // 'keep the hours' + expect(m.minute()).toBe(4); // 'keep the minutes' + expect(m.second()).toBe(0); // 'strip out the seconds' + expect(m.millisecond()).toBe(0); // 'strip out the milliseconds' +}); + +test('end of minute', () => { + const m = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).endOf('minute'); + const ms = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).endOf('minutes'); + const ma = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).endOf('m'); + + expect(Number(m)).toBe(Number(ms)); // 'Plural or singular should work' + expect(Number(m)).toBe(Number(ma)); // 'Full or abbreviated should work' + expect(m.year()).toBe(2011); // 'keep the year' + expect(m.month()).toBe(1); // 'keep the month' + expect(m.date()).toBe(2); // 'keep the day' + expect(m.hour()).toBe(3); // 'keep the hours' + expect(m.minute()).toBe(4); // 'keep the minutes' + expect(m.second()).toBe(59); // 'set the seconds' + expect(m.millisecond()).toBe(999); // 'set the milliseconds' +}); + +test('start of second', () => { + const m = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).startOf('second'); + const ms = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).startOf('seconds'); + const ma = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).startOf('s'); + + expect(Number(m)).toBe(Number(ms)); // 'Plural or singular should work' + expect(Number(m)).toBe(Number(ma)); // 'Full or abbreviated should work' + expect(m.year()).toBe(2011); // 'keep the year' + expect(m.month()).toBe(1); // 'keep the month' + expect(m.date()).toBe(2); // 'keep the day' + expect(m.hour()).toBe(3); // 'keep the hours' + expect(m.minute()).toBe(4); // 'keep the minutes' + expect(m.second()).toBe(5); // 'keep the seconds' + expect(m.millisecond()).toBe(0); // 'strip out the milliseconds' +}); + +test('end of second', () => { + const m = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).endOf('second'); + const ms = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).endOf('seconds'); + const ma = dateTime({input: new Date(2011, 1, 2, 3, 4, 5, 6)}).endOf('s'); + + expect(Number(m)).toBe(Number(ms)); // 'Plural or singular should work' + expect(Number(m)).toBe(Number(ma)); // 'Full or abbreviated should work' + expect(m.year()).toBe(2011); // 'keep the year' + expect(m.month()).toBe(1); // 'keep the month' + expect(m.date()).toBe(2); // 'keep the day' + expect(m.hour()).toBe(3); // 'keep the hours' + expect(m.minute()).toBe(4); // 'keep the minutes' + expect(m.second()).toBe(5); // 'keep the seconds' + expect(m.millisecond()).toBe(999); // 'set the milliseconds' +}); diff --git a/src/dateTime/__tests__/weekday.ts b/src/dateTime/__tests__/weekday.ts new file mode 100644 index 0000000..05a9aa0 --- /dev/null +++ b/src/dateTime/__tests__/weekday.ts @@ -0,0 +1,57 @@ +import {settings} from '../../settings'; +import {dateTime} from '../dateTime'; + +afterEach(() => { + settings.updateLocale({weekStart: 1, yearStart: 1}); +}); + +test('iso weekday', () => { + let i; + + for (i = 0; i < 7; ++i) { + // moment.locale('dow:' + i + ',doy: 6', {week: {dow: i, doy: 6}}); + 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' + expect(dateTime({input: [2013, 3, 24]}).isoWeekday()).toBe(3); // 'Apr 24 2013 is Wednesday -- 3rd day' + expect(dateTime({input: [2015, 2, 5]}).isoWeekday()).toBe(4); // 'Mar 5 2015 is Thursday -- 4th day' + expect(dateTime({input: [1970, 0, 2]}).isoWeekday()).toBe(5); // 'Jan 2 1970 is Friday -- 5th day' + expect(dateTime({input: [2001, 4, 12]}).isoWeekday()).toBe(6); // 'May 12 2001 is Saturday -- 6th day' + expect(dateTime({input: [2000, 0, 2]}).isoWeekday()).toBe(7); // 'Jan 2 2000 is Sunday -- 7th day' + } +}); + +test('iso weekday setter', () => { + let a = dateTime({input: [2011, 0, 10]}); + expect(a.isoWeekday(1).date()).toBe(10); // 'set from mon to mon' + expect(a.isoWeekday(4).date()).toBe(13); // 'set from mon to thu' + expect(a.isoWeekday(7).date()).toBe(16); // 'set from mon to sun' + expect(a.isoWeekday(-6).date()).toBe(3); // 'set from mon to last mon' + expect(a.isoWeekday(-3).date()).toBe(6); // 'set from mon to last thu' + expect(a.isoWeekday(0).date()).toBe(9); // 'set from mon to last sun' + expect(a.isoWeekday(8).date()).toBe(17); // 'set from mon to next mon' + expect(a.isoWeekday(11).date()).toBe(20); // 'set from mon to next thu' + expect(a.isoWeekday(14).date()).toBe(23); // 'set from mon to next sun' + + a = dateTime({input: [2011, 0, 13]}); + expect(a.isoWeekday(1).date()).toBe(10); // 'set from thu to mon' + expect(a.isoWeekday(4).date()).toBe(13); // 'set from thu to thu' + expect(a.isoWeekday(7).date()).toBe(16); // 'set from thu to sun' + expect(a.isoWeekday(-6).date()).toBe(3); // 'set from thu to last mon' + expect(a.isoWeekday(-3).date()).toBe(6); // 'set from thu to last thu' + expect(a.isoWeekday(0).date()).toBe(9); // 'set from thu to last sun' + expect(a.isoWeekday(8).date()).toBe(17); // 'set from thu to next mon' + expect(a.isoWeekday(11).date()).toBe(20); // 'set from thu to next thu' + expect(a.isoWeekday(14).date()).toBe(23); // 'set from thu to next sun' + + a = dateTime({input: [2011, 0, 16]}); + expect(a.isoWeekday(1).date()).toBe(10); // 'set from sun to mon' + expect(a.isoWeekday(4).date()).toBe(13); // 'set from sun to thu' + expect(a.isoWeekday(7).date()).toBe(16); // 'set from sun to sun' + expect(a.isoWeekday(-6).date()).toBe(3); // 'set from sun to last mon' + expect(a.isoWeekday(-3).date()).toBe(6); // 'set from sun to last thu' + expect(a.isoWeekday(0).date()).toBe(9); // 'set from sun to last sun' + expect(a.isoWeekday(8).date()).toBe(17); // 'set from sun to next mon' + 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' +}); diff --git a/src/dateTime/__tests__/zoneSwitching.ts b/src/dateTime/__tests__/zoneSwitching.ts new file mode 100644 index 0000000..3388619 --- /dev/null +++ b/src/dateTime/__tests__/zoneSwitching.ts @@ -0,0 +1,78 @@ +import {dateTime} from '../dateTime'; + +test('local to utc, keepLocalTime = true', () => { + const m = dateTime(); + const fmt = 'YYYY-DD-MM HH:mm:ss'; + expect(m.utc(true).format(fmt)).toBe(m.format(fmt)); +}); + +test('local to utc, keepLocalTime = false', () => { + const m = dateTime(); + expect(m.utc().valueOf()).toBe(m.valueOf()); + expect(m.utc(false).valueOf()).toBe(m.valueOf()); +}); + +test('local to zone, keepLocalTime = true', () => { + const m = dateTime(); + const fmt = 'YYYY-DD-MM HH:mm:ss'; + + // Apparently there is -12:00 and +14:00 + // https://en.wikipedia.org/wiki/UTC+14:00 + // https://en.wikipedia.org/wiki/UTC-12:00 + for (let z = -12; z <= 14; ++z) { + expect(m.utcOffset(z * 60, true).format(fmt)).toBe(m.format(fmt)); + } +}); + +test('local to zone, keepLocalTime = false', () => { + const m = dateTime(); + + // Apparently there is -12:00 and +14:00 + // https://en.wikipedia.org/wiki/UTC+14:00 + // https://en.wikipedia.org/wiki/UTC-12:00 + for (let z = -12; z <= 14; ++z) { + expect(m.utcOffset(z * 60).valueOf()).toBe(m.valueOf()); + expect(m.utcOffset(z * 60, false).valueOf()).toBe(m.valueOf()); + } +}); + +test('utc to local, keepLocalTime = true', () => { + const um = dateTime().utc(); + const fmt = 'YYYY-DD-MM HH:mm:ss'; + + expect(um.local(true).format(fmt)).toBe(um.format(fmt)); +}); + +test('utc to local, keepLocalTime = false', () => { + const um = dateTime().utc(); + expect(um.local().valueOf()).toBe(um.valueOf()); + expect(um.local(false).valueOf()).toBe(um.valueOf()); +}); + +test('zone to local, keepLocalTime = true', () => { + const m = dateTime(); + const fmt = 'YYYY-DD-MM HH:mm:ss'; + + // Apparently there is -12:00 and +14:00 + // https://en.wikipedia.org/wiki/UTC+14:00 + // https://en.wikipedia.org/wiki/UTC-12:00 + for (let z = -12; z <= 14; ++z) { + const tz = m.utcOffset(z * 60); + + expect(tz.local(true).format(fmt)).toBe(tz.format(fmt)); + } +}); + +test('zone to local, keepLocalTime = false', () => { + const m = dateTime(); + + // Apparently there is -12:00 and +14:00 + // https://en.wikipedia.org/wiki/UTC+14:00 + // https://en.wikipedia.org/wiki/UTC-12:00 + for (let z = -12; z <= 14; ++z) { + const tz = m.utcOffset(z * 60); + + expect(tz.local().valueOf()).toBe(tz.valueOf()); + expect(tz.local(false).valueOf()).toBe(tz.valueOf()); + } +}); diff --git a/src/dateTime/dateTime.test.ts b/src/dateTime/dateTime.test.ts index 3b2aac3..519b4e6 100644 --- a/src/dateTime/dateTime.test.ts +++ b/src/dateTime/dateTime.test.ts @@ -6,10 +6,13 @@ import type {DurationUnit} from '../typings'; const MOCKED_DATE = '2021-08-07T12:10:00'; -MockDate.set(MOCKED_DATE); +beforeEach(() => { + MockDate.set(MOCKED_DATE); +}); afterEach(() => { - MockDate.set(MOCKED_DATE); + MockDate.reset(); + settings.updateLocale({weekStart: 1, yearStart: 1}); }); describe('DateTime', () => { @@ -102,5 +105,110 @@ describe('DateTime', () => { const date = dateTime({timeZone: 'UTC'}).add(amount, unit).startOf(durationUnit); expect(date.toISOString()).toEqual(expected); }); + + it('isSame', () => { + const nativeDate = new Date(Date.UTC(2023, 0, 15)); + const date1 = dateTime({input: nativeDate, timeZone: 'Europe/Amsterdam'}); + const date2 = dateTime({input: nativeDate, timeZone: 'America/New_York'}); + expect(date1.isSame(date2)).toBe(true); + expect(date1.isSame(date2.add(1, 'ms'))).toBe(false); + expect(date1.isSame(date2.add(1, 'ms'), 's')).toBe(true); + }); + + it('input without timezone', () => { + const date = dateTime({input: MOCKED_DATE, timeZone: 'Europe/Amsterdam'}); + const amsterdamOffset = 120; + + expect(date.format()).toBe( + dateTime({input: MOCKED_DATE, timeZone: 'UTC'}).utcOffset(amsterdamOffset).format(), + ); + }); + + it('change timeZone', () => { + const date = dateTime({input: '2023-10-29T00:00:00Z', timeZone: 'Europe/Moscow'}); + expect(date.format()).toBe('2023-10-29T03:00:00+03:00'); + expect(date.add(1, 'hour').format()).toBe('2023-10-29T04:00:00+03:00'); + expect(date.timeZone('Europe/Amsterdam').format()).toBe('2023-10-29T02:00:00+02:00'); + expect(date.timeZone('Europe/Amsterdam').add(1, 'hour').format()).toBe( + '2023-10-29T02:00:00+01:00', + ); + expect(date.timeZone('Europe/Amsterdam', true).format()).toBe( + '2023-10-29T03:00:00+01:00', + ); + expect(date.timeZone('Europe/Amsterdam', true).subtract(1, 'hour').format()).toBe( + '2023-10-29T02:00:00+01:00', + ); + expect(date.timeZone('Europe/Amsterdam', true).subtract(2, 'hour').format()).toBe( + '2023-10-29T02:00:00+02:00', + ); + }); + + it('0 offset timeZone', () => { + const date = dateTime({input: '2023-01-01T00:00:00Z', timeZone: 'Europe/London'}); + expect(date.format()).toBe('2023-01-01T00:00:00Z'); + expect(date.add(5, 'months').format()).toBe('2023-06-01T00:00:00+01:00'); + expect(date.add(5, 'months').subtract({M: 4, d: 5}).format()).toBe( + '2023-01-27T00:00:00Z', + ); + + const dateUtc = dateTime({input: '2023-01-01T00:00:00Z', timeZone: 'utc'}); + expect(dateUtc.format()).toBe('2023-01-01T00:00:00Z'); + expect(dateUtc.add(5, 'months').format()).toBe('2023-06-01T00:00:00Z'); + expect(dateUtc.add(5, 'months').subtract({M: 4, d: 5}).format()).toBe( + '2023-01-27T00:00:00Z', + ); + }); + + it('examples from issues', () => { + let date = dateTime({input: '2000-01-01T00:00:00Z', timeZone: 'utc'}); + expect(date.format('D MMMM YYYY HH:mm z')).toBe('1 January 2000 00:00 UTC'); + expect(date.toString()).toBe('Sat, 01 Jan 2000 00:00:00 GMT'); + + expect(dateTime({input: '2000-01-01T00:00:00', timeZone: 'UTC'}).toISOString()).toBe( + dateTime({input: '2000-01-01T00:00:00', timeZone: 'Europe/Moscow'}).toISOString(), + ); + + date = dateTime({input: '2023-11-06T00:00:00', timeZone: 'UTC'}); + expect(date.format('z Z')).toBe('UTC +00:00'); + expect(date.add({day: 1}).format('z Z')).toBe('UTC +00:00'); + expect(date.utcOffset(60).format('z Z')).toBe('UTC +01:00'); + + const nativeDate = new Date('2023-11-06T00:00:00'); + nativeDate.setDate(7); + expect(date.add({day: 1}).toISOString()).toBe(nativeDate.toISOString()); + expect(date.add({day: 1}).toString()).toBe(nativeDate.toUTCString()); + + date = dateTime({input: '2023-11-06T00:00:00', timeZone: 'Europe/London'}); + expect(date.add({day: 1}).toISOString()).toBe(nativeDate.toISOString()); + expect(date.add({day: 1}).toString()).toBe(nativeDate.toUTCString()); + + expect( + dateTime({ + input: new Date(Date.UTC(2023, 0, 26, 0, 0, 0)), + timeZone: 'UTC', + }) + .utcOffset(4 * 24 * 60) + .add(1, 'month') + .format(), + ).toBe('2023-02-28T00:00:00+96:00'); + expect( + dateTime({ + input: '2023-01-30T22:00:00Z', + timeZone: 'Europe/Amsterdam', + }) + .add(6, 'month') + .format(), + ).toBe('2023-07-30T23:00:00+02:00'); + expect( + dateTime({ + input: '2023-08-30T22:00:00Z', + timeZone: 'Europe/Amsterdam', + }) + .set('week', 3) + .format(), + ).toBe('2023-01-12T00:00:00+01:00'); + + expect(dateTime({input: '20130531', format: 'YYYYMMDD'}).month(3).month()).toBe(3); + }); }); }); diff --git a/src/dateTime/dateTime.ts b/src/dateTime/dateTime.ts index 206c1ee..c766d30 100644 --- a/src/dateTime/dateTime.ts +++ b/src/dateTime/dateTime.ts @@ -1,24 +1,498 @@ import dayjs from '../dayjs'; import {STRICT, UtcTimeZone} from '../constants'; -import {compareStrings} from '../utils'; +import {timeZoneOffset, normalizeTimeZone, fixOffset} from '../timeZone'; -import type {ConfigType} from '../dayjs'; -import type {DateTime, DateTimeInput, FormatInput, TimeZone} from '../typings'; +import type { + AllUnit, + DateTime, + DateTimeInput, + DurationUnit, + FormatInput, + SetObject, + StartOfUnit, + TimeZone, +} from '../typings'; +import { + daysInMonth, + normalizeDateComponents, + objToTS, + tsToObject, + getDuration, + offsetFromString, +} from '../utils'; +import {settings} from '../settings'; -export const createDateTime = ( - input?: DateTimeInput, - format?: FormatInput, - timeZone?: TimeZone, -) => { - const date = format ? dayjs(input as ConfigType, format, STRICT) : dayjs(input as ConfigType); +const IS_DATE_TIME = Symbol('isDateTime'); +class DateTimeImpl implements DateTime { + static isDateTime(o: unknown): o is DateTime { + return ( + (typeof o === 'object' && o && IS_DATE_TIME in o && o[IS_DATE_TIME] === true) || false + ); + } - return (timeZone ? date.tz(timeZone) : date) as DateTime; -}; + [IS_DATE_TIME]: boolean; + + private _timestamp: number; + private _timeZone: string; + private _offset: number; + private _locale: string; + private _date: dayjs.Dayjs; + + constructor( + opt: { + input?: DateTimeInput; + format?: FormatInput; + timeZone?: TimeZone; + utcOffset?: 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); + } + } + + format(formatInput?: FormatInput) { + return this._date.format(formatInput); + } + + toISOString(keepOffset?: boolean): string { + if (keepOffset) { + return this._date.format('YYYY-MM-DDTHH:mm:ss.SSSZ'); + } + return this._date.toISOString(); + } + + utcOffset(): number; + utcOffset(offset: string | number, keepLocalTime?: boolean): DateTime; + utcOffset(offset?: string | number, keepLocalTime?: boolean): number | DateTime { + const isSetOffset = offset !== undefined && offset !== null; + if (!this.isValid()) { + return isSetOffset ? this : NaN; + } + if (isSetOffset) { + let newOffset; + if (typeof offset === 'string') { + newOffset = offsetFromString(offset); + if (newOffset === null) { + return this; + } + } else if (Math.abs(offset) < 16) { + newOffset = offset * 60; + } else { + newOffset = offset; + } + let ts = this.valueOf(); + if (keepLocalTime) { + ts -= (newOffset - this._offset) * 60 * 1000; + } + return createDateTime({ + input: ts, + timeZone: UtcTimeZone, + offset: newOffset, + locale: this._locale, + }); + } + + return this._offset; + } + + timeZone(): string; + timeZone(timeZone: string, keepLocalTime?: boolean | undefined): DateTime; + timeZone(timeZone?: string, keepLocalTime?: boolean | undefined): DateTime | string { + if (timeZone === undefined) { + return this._timeZone; + } + + let ts = this.valueOf(); + const zone = normalizeTimeZone(timeZone, settings.getDefaultTimeZone()); + if (keepLocalTime) { + const offset = timeZoneOffset(zone, ts); + ts += this._offset * 60 * 1000; + ts = fixOffset(ts, offset, zone)[0]; + } + + return createDateTime({input: ts, timeZone: zone, locale: this._locale}); + } + + add(amount: DateTimeInput, unit?: DurationUnit): DateTime { + return addSubtract(this, amount, unit, 1); + } + + subtract(amount: DateTimeInput, unit?: DurationUnit): DateTime { + return addSubtract(this, amount, unit, -1); + } + + startOf(unitOfTime: StartOfUnit): DateTime { + // type of startOf is ((unit: QuarterUnit) => DateJs) | ((unit: BaseUnit) => DateJs). + // It cannot get unit of type QuarterUnit | BaseUnit + // @ts-expect-error + const ts = this._date.startOf(unitOfTime).valueOf(); + return createDateTime({ + input: ts, + timeZone: this._timeZone, + offset: this._offset, + locale: this._locale, + }); + } + + endOf(unitOfTime: StartOfUnit): DateTime { + // type of endOf is ((unit: QuarterUnit) => DateJs) | ((unit: BaseUnit) => DateJs). + // It cannot get unit of type QuarterUnit | BaseUnit + // @ts-expect-error + const ts = this._date.endOf(unitOfTime).valueOf(); + return createDateTime({ + input: ts, + timeZone: this._timeZone, + offset: this._offset, + locale: this._locale, + }); + } + + local(keepLocalTime?: boolean): DateTime { + return this.timeZone('default', keepLocalTime); + } + + valueOf(): number { + return this._timestamp; + } + + 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); + } + + 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); + } + + 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); + } + + isValid(): boolean { + return this._date.isValid(); + } + + diff( + amount: DateTimeInput, + unit?: DurationUnit | undefined, + truncate?: boolean | undefined, + ): number { + const value = DateTimeImpl.isDateTime(amount) ? amount.valueOf() : amount; + // value: + // DateTimeInput !== dayjs.ConfigType; + // Array !== [number?, number?, number?, number?, number?, number?, number?] + // unit: + // the same problem as for startOf + // @ts-expect-error + return this._date.diff(value, unit, truncate); + } + fromNow(withoutSuffix?: boolean | undefined): string { + return this._date.fromNow(withoutSuffix); + } + from(formaInput: DateTimeInput, withoutSuffix?: boolean): string { + const value = DateTimeImpl.isDateTime(formaInput) ? formaInput.valueOf() : formaInput; + // DateTimeInput !== dayjs.ConfigType; + // Array !== [number?, number?, number?, number?, number?, number?, number?] + // @ts-expect-error + return this._date.from(value, withoutSuffix); + } + locale(): string; + locale(locale: string): DateTime; + locale(locale?: string): DateTime | string { + if (!locale) { + return this._locale; + } + return createDateTime({ + input: this.valueOf(), + timeZone: this._timeZone, + offset: this._offset, + locale: locale, + }); + } + toDate(): Date { + return new Date(this.valueOf()); + } + unix(): number { + return Math.floor(this.valueOf() / 1000); + } + utc(keepLocalTime?: boolean | undefined): DateTime { + let ts = this.valueOf(); + if (keepLocalTime) { + ts += this._offset * 60 * 1000; + } + return new DateTimeImpl({input: ts, timeZone: UtcTimeZone}); + } + daysInMonth(): number { + return this._date.daysInMonth(); + } + + set(unit: AllUnit | SetObject, amount?: number): DateTime { + const dateComponents = tsToObject(this._timestamp, this._offset); + const newComponents = normalizeDateComponents( + typeof unit === 'object' ? unit : {[unit]: amount}, + ); + + const settingWeekStuff = + newComponents.weekNumber !== undefined || + newComponents.day !== undefined || + newComponents.isoWeekNumber !== undefined || + newComponents.isoWeekday !== undefined; + + const containsYearOrMonthDay = + newComponents.year !== undefined || + newComponents.month !== undefined || + newComponents.date !== undefined; -export const createUTCDateTime = (input?: DateTimeInput, format?: FormatInput) => { - return ( - format ? dayjs.utc(input as ConfigType, format, STRICT) : dayjs.utc(input as ConfigType) - ) as DateTime; + if (settingWeekStuff && containsYearOrMonthDay) { + throw new Error("Can't mix weekYear/weekNumber units with year/month/day"); + } + + let mixed; + if (settingWeekStuff) { + let date = dayjs.utc(objToTS(dateComponents)); + 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; + } + } + mixed = tsToObject(date.valueOf(), 0); + } else { + mixed = {...dateComponents, ...newComponents}; + + if (newComponents.day === undefined) { + mixed.date = Math.min(daysInMonth(mixed.year, mixed.month), mixed.date); + } + } + + let ts = objToTS(mixed); + if (this._timeZone === UtcTimeZone) { + ts -= this._offset * 60 * 1000; + } else { + ts = fixOffset(ts, this._offset, this._timeZone)[0]; + } + + return createDateTime({ + input: ts, + timeZone: this._timeZone, + offset: this._offset, + locale: this._locale, + }); + } + + date(): number; + date(value: number): DateTime; + date(value?: number): number | DateTime { + if (typeof value === 'number') { + return this.set('date', value); + } + return this._date.date(); + } + month(): number; + month(value: number): DateTime; + month(value?: number): number | DateTime { + if (typeof value === 'number') { + return this.set('month', value); + } + return this._date.month(); + } + quarter(): number; + quarter(value: number): DateTime; + quarter(value?: number): number | DateTime { + if (typeof value === 'number') { + return this.set('quarter', value); + } + return this._date.quarter(); + } + year(): number; + year(value: number): DateTime; + year(value?: number): number | DateTime { + if (typeof value === 'number') { + return this.set('year', value); + } + return this._date.year(); + } + day(): number; + day(value: number): DateTime; + day(value?: number): number | DateTime { + if (typeof value === 'number') { + return this.set('day', value); + } + return this._date.day(); + } + + isoWeekday(): number; + isoWeekday(day: number): DateTime; + isoWeekday(day?: number): number | DateTime { + if (day === undefined) { + return this._date.isoWeekday(); + } + + return this.day(this.day() % 7 ? day : day - 7); + } + + hour(): number; + hour(value: number): DateTime; + hour(value?: number): number | DateTime { + if (typeof value === 'number') { + return this.set('hour', value); + } + return this._date.hour(); + } + minute(): number; + minute(value: number): DateTime; + minute(value?: number): number | DateTime { + if (typeof value === 'number') { + return this.set('minute', value); + } + return this._date.minute(); + } + second(): number; + second(value: number): DateTime; + second(value?: number): number | DateTime { + if (typeof value === 'number') { + return this.set('second', value); + } + return this._date.second(); + } + millisecond(): number; + millisecond(value: number): DateTime; + millisecond(value?: number): number | DateTime { + if (typeof value === 'number') { + return this.set('millisecond', value); + } + return this._date.millisecond(); + } + week(): number; + week(value: number): DateTime; + week(value?: number): number | DateTime { + if (typeof value === 'number') { + return this.set('week', value); + } + return this._date.week(); + } + isoWeek(): number; + isoWeek(value: number): DateTime; + isoWeek(value?: number): number | DateTime { + if (typeof value === 'number') { + return this.set('isoWeek', value); + } + return this._date.isoWeek(); + } + + toString(): string { + return this._date.toString(); + } +} + +function addSubtract( + instance: DateTime, + amount: DateTimeInput, + unit: DurationUnit | undefined, + sign: 1 | -1, +) { + const duration = getDuration(amount, unit); + const dateComponents = tsToObject(instance.valueOf(), instance.utcOffset()); + + const monthsInput = absRound(duration.months); + const daysInput = absRound(duration.days); + + let ts = instance.valueOf(); + + if (monthsInput || daysInput) { + const month = dateComponents.month + sign * monthsInput; + const date = + Math.min(dateComponents.date, daysInMonth(dateComponents.year, month)) + + sign * daysInput; + ts = objToTS({...dateComponents, month, date}); + if (instance.timeZone() === UtcTimeZone) { + ts -= instance.utcOffset() * 60 * 1000; + } else { + ts = fixOffset(ts, instance.utcOffset(), instance.timeZone())[0]; + } + } + ts += sign * duration.milliseconds; + + return createDateTime({ + input: ts, + timeZone: instance.timeZone(), + offset: instance.utcOffset(), + locale: instance.locale(), + }); +} + +function absRound(v: number) { + const sign = v < 0 ? -1 : 1; + return Math.round(sign * v) * sign; +} + +function createDateTime({ + input, + timeZone, + offset, + locale, +}: { + input: number; + timeZone?: string; + offset?: number; + locale: string | undefined; +}): DateTime { + const utcOffset = timeZone === UtcTimeZone && offset !== 0 ? offset : undefined; + return new DateTimeImpl({input, timeZone, utcOffset, locale}); +} + +/** + * Checks if value is DateTime. + * @param {unknown} value - value to check. + */ +export const isDateTime = (value: unknown): value is DateTime => { + return DateTimeImpl.isDateTime(value); }; /** @@ -37,28 +511,7 @@ export const dateTime = (opt?: { }): DateTime => { const {input, format, timeZone, lang} = opt || {}; - const prevLang = dayjs.locale(); - const shouldSetLocale = lang && prevLang !== lang; - - if (shouldSetLocale) { - dayjs.locale(lang); - } - - const date = compareStrings(timeZone, UtcTimeZone, {ignoreCase: true}) - ? createUTCDateTime(input, format) - : createDateTime(input, format, timeZone); - - if (shouldSetLocale) { - dayjs.locale(prevLang); - } + const date = new DateTimeImpl({input, format, timeZone, locale: lang}); return date; }; - -/** - * Checks if value is DateTime. - * @param {unknown} value - value to check. - */ -export const isDateTime = (value: unknown): value is DateTime => { - return dayjs.isDayjs(value); -}; diff --git a/src/datemath/datemath.ts b/src/datemath/datemath.ts index 2d34972..57d9a89 100644 --- a/src/datemath/datemath.ts +++ b/src/datemath/datemath.ts @@ -105,7 +105,7 @@ export function parseDateMath( if (isNaN(parseInt(strippedMathString.charAt(i), 10))) { num = 1; } else if (strippedMathString.length === 2) { - num = strippedMathString.charAt(i); + num = parseInt(strippedMathString.charAt(i), 10); } else { const numFrom = i; while (!isNaN(parseInt(strippedMathString.charAt(i), 10))) { diff --git a/src/dayjs.ts b/src/dayjs.ts index d12c4b6..d357685 100644 --- a/src/dayjs.ts +++ b/src/dayjs.ts @@ -3,6 +3,7 @@ import dayjs from 'dayjs'; import arraySupport from 'dayjs/plugin/arraySupport'; import customParseFormat from 'dayjs/plugin/customParseFormat'; import isoWeek from 'dayjs/plugin/isoWeek'; +import weekOfYear from 'dayjs/plugin/weekOfYear'; import objectSupport from 'dayjs/plugin/objectSupport'; import quarterOfYear from 'dayjs/plugin/quarterOfYear'; import relativeTime from 'dayjs/plugin/relativeTime'; @@ -11,22 +12,56 @@ import utc from 'dayjs/plugin/utc'; import localizedFormat from 'dayjs/plugin/localizedFormat'; import updateLocale from 'dayjs/plugin/updateLocale'; import advancedFormat from 'dayjs/plugin/advancedFormat'; +import {fixOffset, timeZoneOffset} from './timeZone'; dayjs.extend(arraySupport); dayjs.extend(customParseFormat); +dayjs.extend(weekOfYear); dayjs.extend(isoWeek); dayjs.extend(quarterOfYear); dayjs.extend(relativeTime); -dayjs.extend(timezone); -// advancedFormat requires timezone plugin +dayjs.extend(localizedFormat); +// advancedFormat must be after localizedFormat dayjs.extend(advancedFormat); +// utc must be after localizedFormat and advancedFormat dayjs.extend(utc); -dayjs.extend(localizedFormat); +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); +dayjs.extend((_, Dayjs, d) => { + const proto = Dayjs.prototype; + + // override `tz` method from timezone plugin + // dayjs incorrectly transform dates to timezone if user local timezone use DST + // and date near a switching time. For example, if local timezone `Europe/Amsterdam` then dayjs gives incorrect result: + // dayjs('2023-10-29T00:00:00Z').valueOf() !== dayjs('2023-10-29T00:00:00Z').tz('Europe/Moscow').valueOf() + // and + // dayjs('2023-10-29T00:00:00Z').tz('Europe/Moscow').format() === '2023-10-29T03:00:00+04:00' + // but should be '2023-10-29T03:00:00+03:00' + proto.tz = function (timeZone: string, keepLocalTime = false) { + let ts = this.valueOf(); + let offset = timeZoneOffset(timeZone, ts); + + if (keepLocalTime) { + const oldOffset = this.utcOffset(); + ts += oldOffset * 60 * 1000; + [ts, offset] = fixOffset(ts, offset, timeZone); + } + + const target = new Date(ts).toLocaleString('en-US', {timeZone}); + // use private members of Dayjs object + // @ts-expect-error + const ins = d(target, {locale: this.$L}).$set('millisecond', ts % 1000); + ins.$offset = offset; + ins.$u = offset === 0; + ins.$x.$timezone = timeZone; + return ins; + }; +}); + export default dayjs; export type {ConfigTypeMap, ConfigType} from 'dayjs'; diff --git a/src/parser/parser.ts b/src/parser/parser.ts index 5d38a24..08cfbe2 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -1,7 +1,6 @@ // Copyright 2015 Grafana Labs // Copyright 2021 YANDEX LLC -import dayjs from '../dayjs'; import {dateTime} from '../dateTime'; import {parse, isValid} from '../datemath'; import type {DateTimeOptionsWhenParsing, DateTime, DateTimeParser} from '../typings'; @@ -46,11 +45,7 @@ export const dateTimeParse: DateTimeParser = ( return undefined; } - dayjs.tz.setDefault(options?.timeZone); - const date = parseInput(input, options); - dayjs.tz.setDefault(); - return date; }; diff --git a/src/settings/settings.test.ts b/src/settings/settings.test.ts index 195eda5..4cfca9b 100644 --- a/src/settings/settings.test.ts +++ b/src/settings/settings.test.ts @@ -1,5 +1,9 @@ import {settings} from './settings'; +afterAll(() => { + settings.setLocale('en'); +}); + describe('settings', () => { it('default locale should be "en"', () => { expect(settings.getLocale()).toEqual('en'); diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 65ff1fb..3d321eb 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -1,13 +1,19 @@ import cloneDeep from 'lodash/cloneDeep'; import dayjs from '../dayjs'; +import {guessUserTimeZone, normalizeTimeZone} from '../timeZone'; import type {UpdateLocaleConfig} from './types'; class Settings { // 'en' - preloaded locale in dayjs private loadedLocales = new Set(['en']); + private defaultLocale = 'en'; + private defaultTimeZone = guessUserTimeZone(); constructor() { - this.updateLocale({weekStart: 1}); + this.updateLocale({ + weekStart: 1, // First day of week is Monday + yearStart: 1, // First week of year must contain 1 January + }); } async loadLocale(locale: string) { @@ -26,7 +32,7 @@ class Settings { } getLocale() { - return dayjs.locale(); + return this.defaultLocale; } getLocaleData() { @@ -51,7 +57,7 @@ class Settings { ); } - dayjs.locale(locale); + this.defaultLocale = locale; } updateLocale(config: UpdateLocaleConfig) { @@ -59,6 +65,14 @@ class Settings { dayjs.updateLocale(locale, config); } + setDefaultTimeZone(zone: 'system' | (string & {})) { + this.defaultTimeZone = normalizeTimeZone(zone, guessUserTimeZone()); + } + + getDefaultTimeZone() { + return this.defaultTimeZone; + } + private isLocaleLoaded(locale: string) { const localeInLowerCase = locale.toLocaleLowerCase(); return this.loadedLocales.has(localeInLowerCase); diff --git a/src/timeZone/timeZone.test.ts b/src/timeZone/timeZone.test.ts new file mode 100644 index 0000000..03de087 --- /dev/null +++ b/src/timeZone/timeZone.test.ts @@ -0,0 +1,31 @@ +import {isValidTimeZone, timeZoneOffset} from './timeZone'; + +describe('timeZone', () => { + it('isValidTimeZone', () => { + expect(isValidTimeZone('')).toBe(false); + expect(isValidTimeZone('UTC')).toBe(true); + expect(isValidTimeZone('GMT')).toBe(true); + expect(isValidTimeZone('GMT+1')).toBe(false); + expect(isValidTimeZone('Etc/GMT+1')).toBe(true); + expect(isValidTimeZone('Europe/Amsterdam')).toBe(true); + }); + + it('timeZoneOffset', () => { + const tsSummerTime = Date.UTC(2023, 5, 1, 0, 0, 0); + const tsWinterTime = Date.UTC(2023, 11, 1, 0, 0, 0); + + expect(timeZoneOffset('UTC', tsSummerTime)).toBe(0); + expect(timeZoneOffset('GMT', tsSummerTime)).toBe(0); + expect(timeZoneOffset('Europe/Amsterdam', tsSummerTime)).toBe(120); + expect(timeZoneOffset('Europe/London', tsSummerTime)).toBe(60); + expect(timeZoneOffset('America/New_York', tsSummerTime)).toBe(-240); + expect(timeZoneOffset('Europe/Moscow', tsSummerTime)).toBe(180); + + expect(timeZoneOffset('UTC', tsWinterTime)).toBe(0); + expect(timeZoneOffset('GMT', tsWinterTime)).toBe(0); + expect(timeZoneOffset('Europe/Amsterdam', tsWinterTime)).toBe(60); + expect(timeZoneOffset('Europe/London', tsWinterTime)).toBe(0); + expect(timeZoneOffset('America/New_York', tsWinterTime)).toBe(-300); + expect(timeZoneOffset('Europe/Moscow', tsWinterTime)).toBe(180); + }); +}); diff --git a/src/timeZone/timeZone.ts b/src/timeZone/timeZone.ts index f6de205..91f2ac6 100644 --- a/src/timeZone/timeZone.ts +++ b/src/timeZone/timeZone.ts @@ -1,10 +1,6 @@ import dayjs from '../dayjs'; import {UtcTimeZone} from '../constants'; -import type {TimeZone, TimeZoneOptions} from '../typings'; - -export const getTimeZone = (options?: T): TimeZone => { - return options?.timeZone ?? UtcTimeZone; -}; +import type {TimeZone} from '../typings'; /** * Returns the user's time zone. @@ -17,3 +13,134 @@ export const guessUserTimeZone = () => dayjs.tz.guess(); // remove when Intl definition is extended // @ts-expect-error https://github.com/microsoft/TypeScript/issues/49231 export const getTimeZonesList = (): string[] => Intl.supportedValuesOf?.('timeZone') || []; + +export function isValidTimeZone(zone: string) { + if (!zone) { + return false; + } + + try { + new Intl.DateTimeFormat('en-US', {timeZone: zone}).format(); + return true; + } catch { + return false; + } +} + +const dateTimeFormatCache: Record = {}; +function makeDateTimeFormat(zone: TimeZone) { + if (!dateTimeFormatCache[zone]) { + dateTimeFormatCache[zone] = new Intl.DateTimeFormat('en-US', { + hour12: false, + timeZone: zone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + era: 'short', + }); + } + return dateTimeFormatCache[zone]; +} + +const dateFields = [ + 'year', + 'month', + 'day', + 'hour', + 'minute', + 'second', + 'era', +] satisfies Intl.DateTimeFormatPartTypes[]; +type DateParts = Record, number> & {era: string}; +export function timeZoneOffset(zone: TimeZone, ts: number) { + const date = new Date(ts); + if (isNaN(date.valueOf()) || !isValidTimeZone(zone)) { + return NaN; + } + + const dtf = makeDateTimeFormat(zone); + const parts = Object.fromEntries( + dtf + .formatToParts(date) + .filter(({type}) => dateFields.includes(type as any)) + .map(({type, value}) => [type, type === 'era' ? value : parseInt(value, 10)]), + ) as DateParts; + + // 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; + const month = parts.month - 1; // month is zero base index + + // https://bugs.chromium.org/p/chromium/issues/detail?id=1025564&can=2&q=%2224%3A00%22%20datetimeformat + const hour = parts.hour === 24 ? 0 : parts.hour; + + let asUTC = Date.UTC(year, month, parts.day, hour, parts.minute, parts.second, 0); + + // years between 0 and 99 are interpreted as 19XX; revert that + if (year < 100 && year >= 0) { + const d = new Date(asUTC); + d.setUTCFullYear(year, month, parts.day); + asUTC = d.valueOf(); + } + + let asTS = date.valueOf(); + const over = asTS % 1000; + asTS -= over >= 0 ? over : 1000 + over; + return (asUTC - asTS) / (60 * 1000); +} + +export function normalizeTimeZone(input: string | undefined, defaultZone: string) { + if (input === undefined || input === null) { + return defaultZone; + } + + const lowered = input.toLowerCase(); + if (lowered === 'utc' || lowered === 'gmt') { + return UtcTimeZone; + } + + if (lowered === 'system') { + return guessUserTimeZone(); + } + + if (lowered === 'default') { + return defaultZone; + } + + if (isValidTimeZone(input)) { + return input; + } + + throw new Error(`InvalidZone: ${input}`); +} + +export function fixOffset( + localTS: number, + o: number, + tz: string, +): [timestamp: number, offset: number] { + // Our UTC time is just a guess because our offset is just a guess + let utcGuess = localTS - o * 60 * 1000; + + // Test whether the zone matches the offset for this ts + const o2 = timeZoneOffset(tz, utcGuess); + + // If so, offset didn't change and we're done + if (o === o2) { + return [utcGuess, o]; + } + + // If not, change the ts by the difference in the offset + utcGuess -= (o2 - o) * 60 * 1000; + + // If that gives us the local time we want, we're done + const o3 = timeZoneOffset(tz, utcGuess); + if (o2 === o3) { + return [utcGuess, o2]; + } + + // 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)]; +} diff --git a/src/typings/dateTime.ts b/src/typings/dateTime.ts index 3e726c5..dab272e 100644 --- a/src/typings/dateTime.ts +++ b/src/typings/dateTime.ts @@ -1,32 +1,24 @@ -import type {ConfigTypeMap} from '../dayjs'; - export type DateTimeInput = - | ConfigTypeMap['objectSupport'] + | InputObject | Date | string | number | Array | DateTime + | null | undefined; export type FormatInput = string | undefined; - -export type DurationUnit = +export type DurationInput = number | string | DurationInputObject | null | undefined; +type BaseUnit = | 'year' | 'years' | 'y' | 'month' | 'months' | 'M' - | 'week' - | 'weeks' - | 'isoWeek' - | 'w' | 'day' | 'days' | 'd' - | 'date' - | 'dates' - | 'D' | 'hour' | 'hours' | 'h' @@ -38,40 +30,83 @@ export type DurationUnit = | 's' | 'millisecond' | 'milliseconds' - | 'ms' - | 'quarter' - | 'quarters' - | 'Q'; + | 'ms'; + +type QuarterUnit = 'quarter' | 'quarters' | 'Q'; +type WeekUnit = 'week' | 'weeks' | 'w'; +type IsoWeekUnit = 'isoWeek' | 'isoWeeks'; // | 'W'; - not supported; +type DateUnit = 'date' | 'dates' | 'D'; +export type StartOfUnit = BaseUnit | QuarterUnit | WeekUnit | IsoWeekUnit | DateUnit; +export type DurationUnit = BaseUnit | QuarterUnit | WeekUnit; +export type AllUnit = + | BaseUnit + | QuarterUnit + | DateUnit + | WeekUnit + | IsoWeekUnit + | 'isoWeekday' + | 'isoWeekdays' + | 'E'; + +export type InputObject = Partial>; +export type DurationInputObject = Partial>; +export type SetObject = Partial< + Record< + | BaseUnit + | QuarterUnit + | DateUnit + | WeekUnit + | IsoWeekUnit + | 'weekday' + | 'weekdays' + | 'e' + | 'isoWeekday' + | 'isoWeekdays' + | 'E', + number + > +>; export interface DateTime extends Object { - add: (amount?: DateTimeInput, unit?: DurationUnit) => DateTime; - set: (unit: DurationUnit, amount: DateTimeInput) => DateTime; - diff: (amount: DateTimeInput, unit?: DurationUnit, truncate?: boolean) => number; - endOf: (unitOfTime: DurationUnit) => DateTime; - format: (formatInput?: FormatInput) => string; - fromNow: (withoutSuffix?: boolean) => string; - from: (formaInput: DateTimeInput) => string; - isSame: (input?: DateTimeInput, granularity?: DurationUnit) => boolean; - isBefore: (input?: DateTimeInput) => boolean; - isAfter: (input?: DateTimeInput) => boolean; - isValid: () => boolean; - local: () => DateTime; - locale: (locale: string) => DateTime; - startOf: (unitOfTime: DurationUnit) => DateTime; - subtract: (amount?: DateTimeInput, unit?: DurationUnit) => DateTime; - toDate: () => Date; - toISOString: (keepOffset?: boolean) => string; - isoWeekday: (day?: number | string) => number | string; - valueOf: () => number; - unix: () => number; - utc: (keepLocalTime?: boolean) => DateTime; - utcOffset(offset: number | string, keepLocalTime?: boolean): DateTime; + add(amount: DurationInput, unit?: DurationUnit): DateTime; + subtract(amount: DurationInput, unit?: DurationUnit): DateTime; + set(unit: AllUnit, amount: number): DateTime; + set(amount: SetObject): DateTime; + diff(amount: DateTimeInput, unit?: DurationUnit, truncate?: 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; + isValid(): boolean; + local(keepLocalTime?: boolean): DateTime; + locale(): string; + locale(locale: string): DateTime; + startOf(unitOfTime: StartOfUnit): DateTime; + endOf(unitOfTime: StartOfUnit): DateTime; + toDate(): Date; + toISOString(keepOffset?: boolean): string; + valueOf(): number; + unix(): number; + utc(keepLocalTime?: boolean): DateTime; utcOffset(): number; + utcOffset(offset: number | string, keepLocalTime?: boolean): DateTime; + timeZone(): string; + timeZone(timeZone: string, keepLocalTime?: boolean): DateTime; daysInMonth: () => number; date(): number; date(value: number): DateTime; + week(): number; + week(value: number): DateTime; + isoWeek(): number; + isoWeek(value: number): DateTime; + isoWeekday(): number; + isoWeekday(value: number): DateTime; month(): number; month(value: number): DateTime; + quarter(): number; + quarter(value: number): DateTime; year(): number; year(value: number): DateTime; day(): number; diff --git a/src/typings/parser.ts b/src/typings/parser.ts index 3cb40aa..2b3feb6 100644 --- a/src/typings/parser.ts +++ b/src/typings/parser.ts @@ -5,7 +5,7 @@ import type {DateTimeOptions} from './common'; import type {DateTime, DateTimeInput} from './dateTime'; export type DateTimeParser = ( - value: DateTimeInput, + value?: DateTimeInput, options?: T, ) => DateTime | undefined; diff --git a/src/utils/duration.ts b/src/utils/duration.ts new file mode 100644 index 0000000..5fcbb1a --- /dev/null +++ b/src/utils/duration.ts @@ -0,0 +1,65 @@ +import isNumber from 'lodash/isNumber'; +import {isDateTime} from '../dateTime'; +import {normalizeDateComponents} from './utils'; + +import type {DateTimeInput, DurationInputObject, DurationUnit, InputObject} from '../typings'; + +const isoRegex = + /^(-|\+)?P(?:([-+]?[0-9,.]*)Y)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)W)?(?:([-+]?[0-9,.]*)D)?(?:T(?:([-+]?[0-9,.]*)H)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)S)?)?$/; + +export interface DurationObject { + milliseconds: number; + days: number; + months: number; +} +// eslint-disable-next-line complexity +export function getDuration(amount: DateTimeInput, unit?: DurationUnit): DurationObject { + let duration: DurationInputObject = {}; + let match: RegExpExecArray | null = null; + if (amount === null || amount === undefined) { + } else if (isDateTime(amount)) { + duration[unit ? unit : 'milliseconds'] = amount.valueOf(); + } else if (isNumber(amount) || !isNaN(Number(amount))) { + duration[unit ? unit : 'milliseconds'] = Number(amount); + } else if (typeof amount === 'string' && (match = isoRegex.exec(amount))) { + const sign = match[1] === '-' ? -1 : 1; + duration = { + y: parseIso(match[2]) * sign, + M: parseIso(match[3]) * sign, + w: parseIso(match[4]) * sign, + d: parseIso(match[5]) * sign, + h: parseIso(match[6]) * sign, + m: parseIso(match[7]) * sign, + s: parseIso(match[8]) * sign, + }; + } else if (typeof amount === 'object') { + duration = amount as InputObject; + } + + const normalizedInput = normalizeDateComponents(duration); + const years = normalizedInput.year || 0; + const quarters = normalizedInput.quarter || 0; + const months = normalizedInput.month || 0; + const weeks = normalizedInput.weekNumber || normalizedInput.isoWeekNumber || 0; + const days = normalizedInput.day || 0; + const hours = normalizedInput.hour || 0; + const minutes = normalizedInput.minute || 0; + const seconds = normalizedInput.second || 0; + const milliseconds = normalizedInput.millisecond || 0; + + const _milliseconds = + milliseconds + seconds * 1000 + minutes * 1000 * 60 + hours * 1000 * 60 * 60; + const _days = Number(days) + weeks * 7; + const _months = Number(months) + quarters * 3 + years * 12; + + return { + milliseconds: _milliseconds, + days: _days, + months: _months, + }; +} + +function parseIso(inp: string) { + const res = inp ? parseFloat(inp.replace(',', '.')) : 0; + return isNaN(res) ? 0 : res; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 04bca77..b2ea06b 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1 +1,2 @@ +export * from './duration'; export * from './utils'; diff --git a/src/utils/utils.test.ts b/src/utils/utils.test.ts deleted file mode 100644 index 0091b61..0000000 --- a/src/utils/utils.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import {CompareStringsOptions, compareStrings} from './utils'; - -describe('utils', () => { - test.each< - [ - string | undefined, - string | undefined, - CompareStringsOptions | undefined, - boolean | undefined, - ] - >([ - ['utc', 'UTC', undefined, false], - [undefined, 'UTC', undefined, false], - ['utc', undefined, undefined, false], - [undefined, undefined, undefined, false], - ['utc', 'UTC', {ignoreCase: true}, true], - ['utc', 'UTC', {ignoreCase: false}, false], - ])( - 'compareStrings (args: {str1: %p, str2: %p, options: %p})', - (str1, str2, options, expected) => { - const result = compareStrings(str1, str2, options); - expect(result).toEqual(expected); - }, - ); -}); diff --git a/src/utils/utils.ts b/src/utils/utils.ts index b512564..9ab50d8 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,25 +1,165 @@ -import {CollatorSensitivity} from '../constants'; +import type {SetObject} from '../typings'; export type CompareStringsOptions = { ignoreCase?: boolean; }; -export const compareStrings = ( - str1?: string, - str2?: string, - options: CompareStringsOptions = {}, -) => { - if (typeof str1 !== 'string' || typeof str2 !== 'string') { - return false; +// x % n but takes the sign of n instead of x +export function floorMod(x: number, n: number) { + return x - n * Math.floor(x / n); +} + +export function isLeapYear(year: number) { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); +} + +export function daysInMonth(year: number, month: number): number { + const modMonth = floorMod(month, 12), + modYear = year + (month - modMonth) / 12; + + if (modMonth === 1) { + return isLeapYear(modYear) ? 29 : 28; + } else { + return [31, -1, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][modMonth]; } +} + +export function tsToObject(ts: number, offset: number) { + const value = ts + offset * 60 * 1000; + + const date = new Date(value); + + return { + year: date.getUTCFullYear(), + month: date.getUTCMonth(), + date: date.getUTCDate(), + hour: date.getUTCHours(), + minute: date.getUTCMinutes(), + second: date.getUTCSeconds(), + millisecond: date.getUTCMilliseconds(), + }; +} +export function objToTS(obj: Record, number>) { + const ts = Date.UTC( + obj.year, + obj.month, + obj.date, + obj.hour, + obj.minute, + obj.second, + obj.millisecond, + ); - const {ignoreCase} = options; - let collatorOptions: Intl.CollatorOptions | undefined; + // for legacy reasons, years between 0 and 99 are interpreted as 19XX; revert that + if (obj.year < 100 && obj.year >= 0) { + const d = new Date(ts); + // 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); - if (typeof ignoreCase === 'boolean') { - const sensitivity = ignoreCase ? CollatorSensitivity.BASE : CollatorSensitivity.CASE; - collatorOptions = {sensitivity}; + return d.valueOf(); } + return ts; +} - return str1.localeCompare(str2, undefined, collatorOptions) === 0; -}; +type NormalizedUnit = + | 'year' + | 'month' + | 'date' + | 'day' + | 'hour' + | 'minute' + | 'second' + | 'millisecond' + | 'quarter' + | 'weekNumber' + | 'isoWeekNumber' + | 'isoWeekday'; + +const normalizedUnits = { + y: 'year', + year: 'year', + years: 'year', + M: 'month', + month: 'month', + months: 'month', + D: 'date', + date: 'date', + dates: 'date', + h: 'hour', + hour: 'hour', + hours: 'hour', + m: 'minute', + minute: 'minute', + minutes: 'minute', + Q: 'quarter', + quarter: 'quarter', + quarters: 'quarter', + s: 'second', + second: 'second', + seconds: 'second', + ms: 'millisecond', + millisecond: 'millisecond', + milliseconds: 'millisecond', + d: 'day', + day: 'day', + days: 'day', + w: 'weekNumber', + week: 'weekNumber', + weeks: 'weekNumber', + W: 'isoWeekNumber', + isoweek: 'isoWeekNumber', + isoweeks: 'isoWeekNumber', + E: 'isoWeekday', + isoweekday: 'isoWeekday', + isoweekdays: 'isoWeekday', +} as const; + +function normalizeComponent(component: string) { + const unit = ['d', 'D', 'm', 'M', 'w', 'W', 'E', 'Q'].includes(component) + ? component + : component.toLowerCase(); + if (unit in normalizedUnits) { + return normalizedUnits[unit as keyof typeof normalizedUnits]; + } + + throw new Error(`Invalid unit ${component}`); +} + +function asNumber(value: unknown) { + const numericValue = Number(value); + if (typeof value === 'boolean' || value === '' || Number.isNaN(numericValue)) { + throw new Error(`Invalid unit value ${value}`); + } + return numericValue; +} + +export function normalizeDateComponents(components: SetObject) { + const normalized: Partial> = {}; + for (const [c, v] of Object.entries(components)) { + if (v === undefined || v === null) continue; + normalized[normalizeComponent(c)] = asNumber(v); + } + return normalized; +} + +const matchOffset = /Z|[+-]\d\d(?::?\d\d)?/gi; // +00 -00 +00:00 -00:00 +0000 -0000 or Z +// timezone chunker +// '+10:00' > ['10', '00'] +// '-1530' > ['-15', '30'] +const chunkOffset = /([+-]|\d\d)/gi; + +export function offsetFromString(value: string | undefined) { + const matches = (value || '').match(matchOffset); + + if (matches === null) { + return null; + } + + const chunk = matches[matches.length - 1] || ''; + const [sign, h, m] = String(chunk).match(chunkOffset) || ['-', 0, 0]; + const minutes = Number(Number(h) * 60) + (isFinite(Number(m)) ? Number(m) : 0); + + return sign === '+' ? minutes : -minutes; +} diff --git a/tsconfig.json b/tsconfig.json index 0c2afda..0da444e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,14 +1,13 @@ { "extends": "@gravity-ui/tsconfig/tsconfig", "compilerOptions": { + "noEmit": true, "target": "es5", "allowJs": false, - "declaration": true, "importsNotUsedAsValues": "error", - "outDir": "build" }, "include": [ - "src" + "**/*" ], "exclude": [ "build" diff --git a/tsconfig.publish.json b/tsconfig.publish.json new file mode 100644 index 0000000..ef71be8 --- /dev/null +++ b/tsconfig.publish.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "outDir": "build" + }, + "include": [ + "src" + ], + "exclude": [ + "**/*.test.ts", + "**/__tests__/**/*" + ] +}