From fa5530233f47c1dc5dfe72b6d1e8f23d79f7b27d Mon Sep 17 00:00:00 2001
From: Valerii Sidorenko <balepas@yandex-team.ru>
Date: Sat, 11 Nov 2023 22:40:06 +0100
Subject: [PATCH] fix(dateTime)!: correctly works with timezones, utc offsets
 and DST (#36)

---
 .eslintignore                           |   1 +
 package-lock.json                       |   6 +-
 package.json                            |   4 +-
 src/constants/index.ts                  |   1 -
 src/constants/utils.ts                  |   7 -
 src/dateTime/__tests__/addSubtract.ts   | 246 +++++++++++
 src/dateTime/__tests__/offset.ts        |  66 +++
 src/dateTime/__tests__/startEndOf.ts    | 317 ++++++++++++++
 src/dateTime/__tests__/weekday.ts       |  57 +++
 src/dateTime/__tests__/zoneSwitching.ts |  78 ++++
 src/dateTime/dateTime.test.ts           | 112 ++++-
 src/dateTime/dateTime.ts                | 527 ++++++++++++++++++++++--
 src/datemath/datemath.ts                |   2 +-
 src/dayjs.ts                            |  41 +-
 src/parser/parser.ts                    |   5 -
 src/settings/settings.test.ts           |   4 +
 src/settings/settings.ts                |  20 +-
 src/timeZone/timeZone.test.ts           |  31 ++
 src/timeZone/timeZone.ts                | 137 +++++-
 src/typings/dateTime.ts                 | 111 +++--
 src/typings/parser.ts                   |   2 +-
 src/utils/duration.ts                   |  65 +++
 src/utils/index.ts                      |   1 +
 src/utils/utils.test.ts                 |  25 --
 src/utils/utils.ts                      | 170 +++++++-
 tsconfig.json                           |   5 +-
 tsconfig.publish.json                   |  15 +
 27 files changed, 1905 insertions(+), 151 deletions(-)
 create mode 100644 .eslintignore
 delete mode 100644 src/constants/utils.ts
 create mode 100644 src/dateTime/__tests__/addSubtract.ts
 create mode 100644 src/dateTime/__tests__/offset.ts
 create mode 100644 src/dateTime/__tests__/startEndOf.ts
 create mode 100644 src/dateTime/__tests__/weekday.ts
 create mode 100644 src/dateTime/__tests__/zoneSwitching.ts
 create mode 100644 src/timeZone/timeZone.test.ts
 create mode 100644 src/utils/duration.ts
 delete mode 100644 src/utils/utils.test.ts
 create mode 100644 tsconfig.publish.json

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<string, number> !== [number?, number?, number?, number?, number?, number?, number?]
+              // @ts-expect-error
+              dayjs(input, opt.format, locale, STRICT)
+            : // DateTimeInput !== dayjs.ConfigType;
+              // Array<string, number> !== [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<string, number> !== [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<string, number> !== [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<string, number> !== [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<string, number> !== [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<string, number> !== [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<DateTimeOptionsWhenParsing> = (
         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 = <T extends TimeZoneOptions>(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<TimeZone, Intl.DateTimeFormat> = {};
+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<Exclude<(typeof dateFields)[number], 'era'>, 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<string | number>
     | 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<Record<BaseUnit | DateUnit, number>>;
+export type DurationInputObject = Partial<Record<DurationUnit, number>>;
+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<T extends DateTimeOptions = DateTimeOptions> = (
-    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<keyof ReturnType<typeof tsToObject>, 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<Record<NormalizedUnit, number>> = {};
+    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__/**/*"
+  ]
+}