Repository: obsidian-tasks-group/obsidian-tasks
Pull request #1837 (2023-04-04 18:45:26)

refactor: date range abstraction
<!--- Provide a general summary of your changes in the Title above -->

# Description

<!--- Describe your changes in detail -->

## Motivation and Context

For further development of filters with date range, there is a need to refactor the current code and extract a `DateRange` class for easier manipulation.

Dealing with `[moment.Moment, moment.Moment]` is damaging code readability.

## How has this been tested?

Unit tests. Pure refactor.

## Screenshots (if appropriate)

## Types of changes

<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->

Changes visible to users:

- [ ] **Bug fix** (prefix: `fix` - non-breaking change which fixes an issue)
- [ ] **New feature** (prefix: `feat` - non-breaking change which adds functionality)
- [ ] **Breaking change** (prefix: `feat!!` or `fix!!` - fix or feature that would cause existing functionality to not work as expected)
- [ ] **Documentation** (prefix: `docs` - improvements to any documentation content **for users**)
- [ ] **Sample vault** (prefix: `vault` - improvements to the [Tasks-Demo sample vault](https://github.com/obsidian-tasks-group/obsidian-tasks/tree/main/resources/sample_vaults/Tasks-Demo))
- [ ] **Contributing Guidelines** (prefix: `contrib` - any improvements to documentation content **for contributors** - see [Contributing to Tasks](https://publish.obsidian.md/tasks-contributing/))

Internal changes:

- [x] **Refactor** (prefix: `refactor` - non-breaking change which only improves the design or structure of existing code, and making no changes to its external behaviour)
- [x] **Tests** (prefix: `test` - additions and improvements to unit tests and the smoke tests)
- [ ] **Infrastructure** (prefix: `chore` - examples include GitHub Actions, issue templates)

## Checklist

<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

- [x] My code follows the code style of this project and passes `yarn run lint`.
- [ ] My change requires a change to the documentation.
- [ ] I have [updated the documentation](https://publish.obsidian.md/tasks-contributing/Documentation/About+Documentation) accordingly.
- [x] My change has adequate [Unit Test coverage](https://publish.obsidian.md/tasks-contributing/Testing/About+Testing).

## Terms

<!--
By submitting this pull request, you must agree to follow our
[contributing guide](https://publish.obsidian.md/tasks-contributing) and
[Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md).
Put an x in the boxes to confirm you agree.
-->

- [x] My contribution follow this project's [contributing guide](https://publish.obsidian.md/tasks-contributing)
- [x] I agree to follow this project's [Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md)


claremacrae: Many thanks for doing this.

I think this is fine now, with the docs. Definitely an improvement.

Let me know if you're OK with merging it.
ilandikov: I did couple of minor changes and rebased on the top. I think we are good to go =) thanks a lot for consideration

Patch:
diff --git a/docs/Queries/Filters.md b/docs/Queries/Filters.md
index 5fdfd1c7..b7d3e4eb 100644
--- a/docs/Queries/Filters.md
+++ b/docs/Queries/Filters.md
@@ -124,7 +124,7 @@ There is also the ability to use numbered date ranges that are independent of th
   - Example: `2023`
 
 > [!released]
-> Numbered date ranges were introduced in Tasks X.Y.Z.
+> Numbered date ranges were introduced in Tasks 3.1.0.
 
 ### Troubleshooting date searches
 
@@ -230,7 +230,7 @@ For full details of combining filters with boolean operators, see [[Combining Fi
 > - `no done date` and `has done date` were introduced in Tasks 1.7.0.
 > - `done date is invalid` was introduced in Tasks 1.16.0.
 > - `done (before|after|in) <date range>` searches were introduced in Tasks 2.0.0.
-> - `done (before|after|in) (YYYY-Www|YYYY-mm|YYYY-Qq|YYYY)` searches were introduced in Tasks X.Y.Z.
+> - `done (before|after|in) (YYYY-Www|YYYY-mm|YYYY-Qq|YYYY)` searches were introduced in Tasks 3.1.0.
 
 ### Due Date
 
@@ -248,7 +248,7 @@ For full details of combining filters with boolean operators, see [[Combining Fi
 > - `has due date` was introduced in Tasks 1.6.0.
 > - `due date is invalid` was introduced in Tasks 1.16.0.
 > - `due (before|after|in) <date range>` searches were introduced in Tasks 2.0.0.
-> - `due (before|after|in) (YYYY-Www|YYYY-mm|YYYY-Qq|YYYY)` searches were introduced in Tasks X.Y.Z.
+> - `due (before|after|in) (YYYY-Www|YYYY-mm|YYYY-Qq|YYYY)` searches were introduced in Tasks 3.1.0.
 
 ### Scheduled Date
 
@@ -266,7 +266,7 @@ For full details of combining filters with boolean operators, see [[Combining Fi
 > - `has scheduled date` was introduced in Tasks 1.6.0.
 > - `scheduled date is invalid` was introduced in Tasks 1.16.0.
 > - `scheduled (before|after|in) <date range>` searches were introduced in Tasks 2.0.0.
-> - `scheduled (before|after|in) (YYYY-Www|YYYY-mm|YYYY-Qq|YYYY)` searches were introduced in Tasks X.Y.Z.
+> - `scheduled (before|after|in) (YYYY-Www|YYYY-mm|YYYY-Qq|YYYY)` searches were introduced in Tasks 3.1.0.
 
 ### Start Date
 
@@ -284,7 +284,7 @@ For full details of combining filters with boolean operators, see [[Combining Fi
 > - `has start date` was Introduced in Tasks 1.6.0.
 > - `start date is invalid` was introduced in Tasks 1.16.0.
 > - `starts (before|after|in) <date range>` searches were introduced in Tasks 2.0.0.
-> - `starts (before|after|in) (YYYY-Www|YYYY-mm|YYYY-Qq|YYYY)` searches were introduced in Tasks X.Y.Z.
+> - `starts (before|after|in) (YYYY-Www|YYYY-mm|YYYY-Qq|YYYY)` searches were introduced in Tasks 3.1.0.
 
 When filtering queries by [[Dates#🛫 Start|start date]],
 the result will include tasks without a start date.
@@ -318,7 +318,7 @@ Such a filter could be:
 > [!released]
 >
 > - Created date was introduced in Tasks 2.0.0.
-> - `created (before|after|in) (YYYY-Www|YYYY-mm|YYYY-Qq|YYYY)` searches were introduced in Tasks X.Y.Z.
+> - `created (before|after|in) (YYYY-Www|YYYY-mm|YYYY-Qq|YYYY)` searches were introduced in Tasks 3.1.0.
 
 ### Happens
 
@@ -342,7 +342,7 @@ because the tasks starts before tomorrow. Only one of the dates needs to match.
 >
 > - `no happens date` and `has happens date` were introduced in Tasks 1.7.0.
 > - `happens (before|after|in) <date range>` searches were introduced in Tasks 2.0.0.
-> - `happens (before|after|in) (YYYY-Www|YYYY-mm|YYYY-Qq|YYYY)` searches were introduced in Tasks X.Y.Z.
+> - `happens (before|after|in) (YYYY-Www|YYYY-mm|YYYY-Qq|YYYY)` searches were introduced in Tasks 3.1.0.
 
 ## Filters for Task Statuses
 
diff --git a/manifest.json b/manifest.json
index ad2a3d36..b1949fa5 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,7 +1,7 @@
 {
   "id": "obsidian-tasks-plugin",
   "name": "Tasks",
-  "version": "3.0.0",
+  "version": "3.1.0",
   "minAppVersion": "0.14.6",
   "description": "Task management for Obsidian",
   "author": "Martin Schenck and Clare Macrae",
diff --git a/package.json b/package.json
index fd24b27f..e6ccc633 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "obsidian-tasks",
-  "version": "3.0.0",
+  "version": "3.1.0",
   "description": "Task management for the Obsidian knowledge base",
   "main": "main.js",
   "scripts": {
diff --git a/src/Query/DateParser.ts b/src/Query/DateParser.ts
index 2847bd8e..56c18780 100644
--- a/src/Query/DateParser.ts
+++ b/src/Query/DateParser.ts
@@ -1,16 +1,15 @@
 import * as chrono from 'chrono-node';
 import moment from 'moment';
+import { DateRange } from './DateRange';
 
 export class DateParser {
     public static parseDate(input: string, forwardDate: boolean = false): moment.Moment {
         // Using start of day to correctly match on comparison with other dates (like equality).
-        return window
-            .moment(
-                chrono.parseDate(input, undefined, {
-                    forwardDate: forwardDate,
-                }),
-            )
-            .startOf('day');
+        return moment(
+            chrono.parseDate(input, undefined, {
+                forwardDate: forwardDate,
+            }),
+        ).startOf('day');
     }
 
     /**
@@ -19,79 +18,70 @@ export class DateParser {
      *                or a single date.
      * @return - A Tuple of dates. If both input dates are invalid, then both ouput dates will be invalid.
      */
-    public static parseDateRange(input: string): [moment.Moment, moment.Moment] {
+    public static parseDateRange(input: string): DateRange {
         const dateRangeParsers = [
             // Try parsing a relative date range like 'current month'
             DateParser.parseRelativeDateRange,
             // Try '2022-W10' otherwise
-            DateParser.parseSpecificDateRange,
+            DateParser.parseNumberedDateRange,
             // If previous failed, fallback on absolute date range with chrono
             DateParser.parseAbsoluteDateRange,
         ];
 
         for (const parser of dateRangeParsers) {
             const parsedDateRange = parser(input);
-            if (parsedDateRange !== undefined) {
+            if (parsedDateRange.isValid()) {
                 return parsedDateRange;
             }
         }
 
         // If nothing worked return and invalid date range
-        return [moment.invalid(), moment.invalid()];
+        return DateRange.buildInvalid();
     }
 
-    private static parseAbsoluteDateRange(input: string): [moment.Moment, moment.Moment] | undefined {
+    private static parseAbsoluteDateRange(input: string): DateRange {
         const result = chrono.parse(input, undefined, {
             forwardDate: true,
         });
 
         // Check chrono parsing
         if (result.length === 0) {
-            return undefined;
+            return DateRange.buildInvalid();
         }
 
         const startDate = result[0].start;
         const endDate = result[1] && result[1].start ? result[1].start : startDate;
-        const start = window.moment(startDate.date());
-        const end = window.moment(endDate.date());
+        const start = moment(startDate.date());
+        const end = moment(endDate.date());
 
-        let absoluteDateRange: [moment.Moment, moment.Moment] = [start, end];
-        if (end.isBefore(start)) {
-            absoluteDateRange = [end, start];
-        }
-
-        return DateParser.setDateRangeToStartOfDay(absoluteDateRange);
+        return new DateRange(start, end);
     }
 
-    private static parseRelativeDateRange(input: string): [moment.Moment, moment.Moment] | undefined {
+    private static parseRelativeDateRange(input: string): DateRange {
         const relativeDateRangeRegexp = /(last|this|next) (week|month|quarter|year)/;
         const relativeDateRangeMatch = input.match(relativeDateRangeRegexp);
         if (relativeDateRangeMatch && relativeDateRangeMatch.length === 3) {
             const lastThisNext = relativeDateRangeMatch[1];
             const range = relativeDateRangeMatch[2];
 
-            const delta = moment.duration(1, range as moment.unitOfTime.DurationConstructor);
+            const dateRange = DateRange.buildRelative(range as moment.unitOfTime.StartOf);
 
-            let dateRange: [moment.Moment, moment.Moment] = [moment(), moment()];
             switch (lastThisNext) {
                 case 'last':
-                    dateRange.forEach((d) => d.subtract(delta));
+                    dateRange.moveToPrevious(range as moment.unitOfTime.DurationConstructor);
                     break;
                 case 'next':
-                    dateRange.forEach((d) => d.add(delta));
+                    dateRange.moveToNext(range as moment.unitOfTime.DurationConstructor);
                     break;
             }
 
-            const unitOfTime = range === 'week' ? 'isoWeek' : (range as moment.unitOfTime.DurationConstructor);
-            dateRange = [dateRange[0].startOf(unitOfTime), dateRange[1].endOf(unitOfTime)];
-
-            return DateParser.setDateRangeToStartOfDay(dateRange);
+            return dateRange;
         }
 
-        return undefined;
+        return DateRange.buildInvalid();
     }
 
-    private static parseSpecificDateRange(input: string): [moment.Moment, moment.Moment] | undefined {
+    private static parseNumberedDateRange(input: string): DateRange {
         const parsingVectors: [RegExp, string, moment.unitOfTime.StartOf][] = [
             [/^\s*[0-9]{4}\s*$/, 'YYYY', 'year'],
             [/^\s*[0-9]{4}-Q[1-4]\s*$/, 'YYYY-Q', 'quarter'],
@@ -99,25 +89,15 @@ export class DateParser {
             [/^\s*[0-9]{4}-W[0-9]{2}\s*$/, 'YYYY-WW', 'isoWeek'],
         ];
 
-        for (const [regexp, format, unit] of parsingVectors) {
+        for (const [regexp, dateFormat, range] of parsingVectors) {
             const matched = input.match(regexp);
             if (matched) {
                 // RegExps allow spaces (\s*), remove them before calling moment()
-                const range = matched[0].trim();
-                const parsedRange: [moment.Moment, moment.Moment] = [
-                    moment(range, format).startOf(unit),
-                    moment(range, format).endOf(unit),
-                ];
-                return DateParser.setDateRangeToStartOfDay(parsedRange);
+                const date = matched[0].trim();
+                return new DateRange(moment(date, dateFormat).startOf(range), moment(date, dateFormat).endOf(range));
             }
         }
 
-        return undefined;
-    }
-
-    private static setDateRangeToStartOfDay(dateRange: [moment.Moment, moment.Moment]): [moment.Moment, moment.Moment] {
-        // Dates shall be at midnight eg 00:00
-        dateRange.forEach((d) => d.startOf('day'));
-        return dateRange;
+        return DateRange.buildInvalid();
     }
 }
diff --git a/src/Query/DateRange.ts b/src/Query/DateRange.ts
new file mode 100644
index 00000000..347a9f74
--- /dev/null
+++ b/src/Query/DateRange.ts
@@ -0,0 +1,96 @@
+import moment from 'moment';
+
+/**
+ * Represent an inclusive span of time between two days at 00:00 local time.
+ */
+export class DateRange {
+    start: moment.Moment;
+    end: moment.Moment;
+
+    /**
+     * Builds the date range. If start is after the end, the dates will be automatically reversed.
+     * Start and end will be saved at 00:00 local time.
+     * The stored values of are mutable.
+     * Note that there is no validation of the start and end moment. They can be checked with start.isValid() and end.isValid().
+     * @param start
+     * @param end
+     */
+    constructor(start: moment.Moment, end: moment.Moment) {
+        this.start = start;
+        this.end = end;
+
+        if (end.isBefore(start)) {
+            this.start = end;
+            this.end = start;
+        }
+
+        // Dates shall be at midnight eg 00:00
+        this.start = this.start.startOf('day');
+        this.end = this.end.startOf('day');
+    }
+
+    /**
+     * Builds a date range relative to today like this week/month/quarter/year.
+     * @example <caption> construct a date range containing the current month.</caption>
+     * const thisMonth = DateRange.buildRelative('month');
+     * @param range one of 'week', 'month', 'quarter', 'year'
+     * @returns
+     */
+    public static buildRelative(range: moment.unitOfTime.StartOf) {
+        // Treat all weeks as ISO 8601 weeks
+        const unitOfTime = range === 'week' ? 'isoWeek' : range;
+
+        return new DateRange(moment().startOf(unitOfTime).startOf('day'), moment().endOf(unitOfTime).startOf('day'));
+    }
+
+    /**
+     * Builds an invalid date range with invalid momentks objects (https://momentjscom.readthedocs.io/en/latest/moment/09-utilities/02-invalid/).
+     * For example if the parsing has gone wrong.
+     * @returns
+     */
+    public static buildInvalid(): DateRange {
+        return new DateRange(moment.invalid(), moment.invalid());
+    }
+
+    /**
+     * Check if the both dates of the date range are valid in terms of momentjs.
+     * @returns true if both dates are valid
+     */
+    public isValid(): boolean {
+        return this.start.isValid() && this.end.isValid();
+    }
+
+    /**
+     * Move the date range to the previous week/month/quarter/year.
+     * Make sure that the duration in the parameters matches the range size, for example subtracting a month from a week long range was not tested.
+     * @param duration one of 'week'/'month'/'quarter'/'year'
+     */
+    public moveToPrevious(duration: moment.unitOfTime.DurationConstructor) {
+        const delta = moment.duration(1, duration);
+        this.start.subtract(delta);
+        this.end.subtract(delta);
+
+        if (duration === 'month' || duration === 'quarter') {
+            // Month and quarter durations in days may differ (28/30/31 days).
+            // We will need to adjust the end.
+            this.end = this.end.endOf(duration).startOf('day');
+        }
+    }
+
+    /**
+     * Move the date range to the next week/month/quarter/year.
+     * Make sure that the duration in the parameters matches the range size, for example subtracting a month from a week long range was not tested.
+     * @param duration one of 'week'/'month'/'quarter'/'year'
+     */
+    public moveToNext(duration: moment.unitOfTime.DurationConstructor) {
+        const delta = moment.duration(1, duration);
+        this.start.add(delta);
+        this.end.add(delta);
+
+        if (duration === 'month' || duration === 'quarter') {
+            // Month and quarter durations in days may differ (28/30/31 days).
+            // We will need to adjust the end.
+            this.end = this.end.endOf(duration).startOf('day');
+        }
+    }
+}
diff --git a/src/Query/Filter/DateField.ts b/src/Query/Filter/DateField.ts
index 9b3c9aab..85c104ad 100644
--- a/src/Query/Filter/DateField.ts
+++ b/src/Query/Filter/DateField.ts
@@ -1,4 +1,5 @@
 import type { Moment } from 'moment';
+import type { DateRange } from '../DateRange';
 import type { Task } from '../../Task';
 import { DateParser } from '../DateParser';
 import { Explanation } from '../Explain/Explanation';
@@ -53,8 +54,8 @@ export abstract class DateField extends Field {
         if (fieldNameKeywordDate !== null) {
             const fieldKeyword = fieldNameKeywordDate[1];
             const fieldDateString = fieldNameKeywordDate[2];
-            const fieldDates: [moment.Moment, moment.Moment] = DateParser.parseDateRange(fieldDateString);
-            if (!fieldDates[0].isValid() || !fieldDates[1].isValid()) {
+            const fieldDates = DateParser.parseDateRange(fieldDateString);
+            if (!fieldDates.isValid()) {
                 result.error = 'do not understand ' + this.fieldName() + ' date';
             } else {
                 const filterFunction = this.buildFilterFunction(fieldKeyword, fieldDates);
@@ -79,16 +80,16 @@ export abstract class DateField extends Field {
      * @param fieldDates the date range to be used by the filter function
      * @returns the function that filters the tasks
      */
-    protected buildFilterFunction(fieldKeyword: string, fieldDates: [moment.Moment, moment.Moment]): FilterFunction {
+    protected buildFilterFunction(fieldKeyword: string, fieldDates: DateRange): FilterFunction {
         let dateFilter: DateFilterFunction;
         if (fieldKeyword === 'before') {
-            dateFilter = (date) => (date ? date.isBefore(fieldDates[0]) : this.filterResultIfFieldMissing());
+            dateFilter = (date) => (date ? date.isBefore(fieldDates.start) : this.filterResultIfFieldMissing());
         } else if (fieldKeyword === 'after') {
-            dateFilter = (date) => (date ? date.isAfter(fieldDates[1]) : this.filterResultIfFieldMissing());
+            dateFilter = (date) => (date ? date.isAfter(fieldDates.end) : this.filterResultIfFieldMissing());
         } else {
             dateFilter = (date) =>
                 date
-                    ? date.isSameOrAfter(fieldDates[0]) && date.isSameOrBefore(fieldDates[1])
+                    ? date.isSameOrAfter(fieldDates.start) && date.isSameOrBefore(fieldDates.end)
                     : this.filterResultIfFieldMissing();
         }
         return this.getFilter(dateFilter);
@@ -129,7 +130,7 @@ export abstract class DateField extends Field {
         fieldName: string,
         fieldKeyword: string,
         filterResultIfFieldMissing: boolean,
-        filterDates: [moment.Moment, moment.Moment],
+        filterDates: DateRange,
     ): Explanation {
         let relationship;
         // Example of formatted date: '2024-01-02 (Tuesday 2nd January 2024)'
@@ -138,16 +139,16 @@ export abstract class DateField extends Field {
         switch (fieldKeyword) {
             case 'before':
                 relationship = fieldKeyword;
-                explanationDates = filterDates[0].format(dateFormat);
+                explanationDates = filterDates.start.format(dateFormat);
                 break;
             case 'after':
                 relationship = fieldKeyword;
-                explanationDates = filterDates[1].format(dateFormat);
+                explanationDates = filterDates.end.format(dateFormat);
                 break;
             default:
-                if (filterDates[0].isSame(filterDates[1])) {
+                if (filterDates.start.isSame(filterDates.end)) {
                     relationship = 'on';
-                    explanationDates = filterDates[0].format(dateFormat);
+                    explanationDates = filterDates.start.format(dateFormat);
                 } else {
                     // This is a special case where a multi-line explanation has to be built
                     // All other cases need only one line
@@ -155,8 +156,8 @@ export abstract class DateField extends Field {
 
                     // Consecutive lines
                     const subExplanations = [
-                        new Explanation(`${filterDates[0].format(dateFormat)} and`),
-                        new Explanation(`${filterDates[1].format(dateFormat)} inclusive`),
+                        new Explanation(`${filterDates.start.format(dateFormat)} and`),
+                        new Explanation(`${filterDates.end.format(dateFormat)} inclusive`),
                     ];
 
                     // Optional line for StartDateField (so far)
diff --git a/versions.json b/versions.json
index c30fd15a..ccdf754a 100644
--- a/versions.json
+++ b/versions.json
@@ -56,5 +56,6 @@
   "1.25.0": "0.14.6",
   "2.0.0": "0.14.6",
   "2.0.1": "0.14.6",
-  "3.0.0": "0.14.6"
+  "3.0.0": "0.14.6",
+  "3.1.0": "0.14.6"
 }


Test patch:
diff --git a/tests/Query/DateParser.test.ts b/tests/Query/DateParser.test.ts
index 5ecd3cca..2cfe3510 100644
--- a/tests/Query/DateParser.test.ts
+++ b/tests/Query/DateParser.test.ts
@@ -1,12 +1,9 @@
 /**
  * @jest-environment jsdom
  */
-import moment from 'moment';
 import { DateParser } from '../../src/Query/DateParser';
 import { TaskRegularExpressions } from '../../src/Task';
 
-window.moment = moment;
-
 function testParsingeSingleDate(input: string, result: string) {
     const moment = DateParser.parseDate(input);
     expect(moment.format(TaskRegularExpressions.dateFormat)).toEqual(result);
@@ -17,12 +14,10 @@ function testParsingDateRange(input: string, expectedStart: string, expectedEnd:
     const result = DateParser.parseDateRange(input);
 
     // Assert
-    const start = result[0];
-    const end = result[1];
-    expect(start).toBeDefined();
-    expect(end).toBeDefined();
-    const startFmt = start.format(TaskRegularExpressions.dateFormat);
-    const endFmt = end.format(TaskRegularExpressions.dateFormat);
+    expect(result.start).toBeDefined();
+    expect(result.end).toBeDefined();
+    const startFmt = result.start.format(TaskRegularExpressions.dateFormat);
+    const endFmt = result.end.format(TaskRegularExpressions.dateFormat);
     expect([startFmt, endFmt]).toStrictEqual([expectedStart, expectedEnd]);
 }
 
@@ -74,13 +69,6 @@ describe('DateParser - date ranges', () => {
     it('should return 2 invalid dates when both dates are invalid', () => {
         testParsingDateRange('2015-99-29 2015-99-29', 'Invalid date', 'Invalid date');
     });
-
-    it('should return date ranges at midnight', () => {
-        const dateRangeToParse = '2023-09-28 2023-10-01';
-        const parsedDateRange = DateParser.parseDateRange(dateRangeToParse);
-        expect(parsedDateRange[0].format('YYYY-MM-DD HH:mm')).toStrictEqual('2023-09-28 00:00');
-        expect(parsedDateRange[1].format('YYYY-MM-DD HH:mm')).toStrictEqual('2023-10-01 00:00');
-    });
 });
 
 describe('DateParser - relative date ranges', () => {
@@ -93,13 +81,6 @@ describe('DateParser - relative date ranges', () => {
         jest.useRealTimers();
     });
 
-    it('should return relative date range at midnight (week)', () => {
-        const dateRangeToParse = 'this week';
-        const parsedDateRange = DateParser.parseDateRange(dateRangeToParse);
-        expect(parsedDateRange[0].format('YYYY-MM-DD HH:mm')).toStrictEqual('2021-10-04 00:00');
-        expect(parsedDateRange[1].format('YYYY-MM-DD HH:mm')).toStrictEqual('2021-10-10 00:00');
-    });
-
     it('should return relative date range (week)', () => {
         testParsingDateRange('last week', '2021-09-27', '2021-10-03');
         testParsingDateRange('this week', '2021-10-04', '2021-10-10');
@@ -125,28 +106,8 @@ describe('DateParser - relative date ranges', () => {
     });
 });
 
-describe('Date Parser - correct delta for next & last month & quarter (Today is 2021-04-03)', () => {
-    beforeAll(() => {
-        jest.useFakeTimers();
-        jest.setSystemTime(new Date(2021, 3, 3)); // 2021-04-03
-    });
-
-    afterAll(() => {
-        jest.useRealTimers();
-    });
-
-    it('should return longer range even if the current range is shorter in days', () => {
-        testParsingDateRange('last month', '2021-03-01', '2021-03-31');
-        testParsingDateRange('next month', '2021-05-01', '2021-05-31');
-        testParsingDateRange('last quarter', '2021-01-01', '2021-03-31');
-        testParsingDateRange('next quarter', '2021-07-01', '2021-09-30');
-        // The latest test case is not representative eg won't fail without the fix
-        // because the length of Q2 in days is same as Q3.
-    });
-});
-
-describe('DateParser - specific date ranges', () => {
-    it('should return dates for specific ranges', () => {
+describe('DateParser - numbered date ranges', () => {
+    it('should return dates for numbered ranges', () => {
         // Week (53 in a year)
         testParsingDateRange('2020-W53', '2020-12-28', '2021-01-03');
         testParsingDateRange('2021-W01', '2021-01-04', '2021-01-10');
@@ -177,21 +138,7 @@ describe('DateParser - specific date ranges', () => {
         testParsingDateRange('  2020-03        ', '2020-03-01', '2020-03-31');
     });
 
-    it.each([
-        ['2018-W38', '2018-09-17', '2018-09-23'],
-        ['2010-11', '2010-11-01', '2010-11-30'],
-        ['2019-Q3', '2019-07-01', '2019-09-30'],
-        ['2007', '2007-01-01', '2007-12-31'],
-    ])(
-        'specific range %s: should return %s and %s at midnight',
-        (range: string, rangeStart: string, rangeEnd: string) => {
-            const parsedDateRange = DateParser.parseDateRange(range);
-            expect(parsedDateRange[0].format('YYYY-MM-DD HH:mm')).toStrictEqual(`${rangeStart} 00:00`);
-            expect(parsedDateRange[1].format('YYYY-MM-DD HH:mm')).toStrictEqual(`${rangeEnd} 00:00`);
-        },
-    );
-
-    it('should return invalid dates for erroneous specific ranges', () => {
+    it('should return invalid dates for erroneous numbered ranges', () => {
         // Week
         // Each 4 years a year has 53 weeks (2020). For 2020 the week 54 should be invalid then.
         // For the others it is the week 53 that should be invalid.
diff --git a/tests/Query/DateRange.test.ts b/tests/Query/DateRange.test.ts
new file mode 100644
index 00000000..89b60d03
--- /dev/null
+++ b/tests/Query/DateRange.test.ts
@@ -0,0 +1,135 @@
+import moment from 'moment';
+import { DateRange } from '../../src/Query/DateRange';
+
+function testDateRange(dateRange: DateRange, start: string, end: string) {
+    expect(dateRange.start).toBeDefined();
+    expect(dateRange.end).toBeDefined();
+    expect(dateRange.isValid()).toEqual(true);
+    expect(dateRange.start.format('YYYY-MM-DD HH:mm')).toStrictEqual(`${start} 00:00`);
+    expect(dateRange.end.format('YYYY-MM-DD HH:mm')).toStrictEqual(`${end} 00:00`);
+}
+
+describe('DateRange - absolute date ranges', () => {
+    it('should build date range', () => {
+        const dateRange = new DateRange(moment('2023-09-28'), moment('2023-10-01'));
+        testDateRange(dateRange, '2023-09-28', '2023-10-01');
+    });
+
+    it('should return date range even if dates are reversed', () => {
+        const dateRange = new DateRange(moment('2023-08-02'), moment('2017-11-02'));
+        testDateRange(dateRange, '2017-11-02', '2023-08-02');
+    });
+});
+
+describe('DateRange - relative date ranges', () => {
+    beforeAll(() => {
+        jest.useFakeTimers();
+        jest.setSystemTime(new Date(2021, 9, 6)); // 2021-10-06
+    });
+
+    afterAll(() => {
+        jest.useRealTimers();
+    });
+
+    it.each([
+        ['week', '2021-10-04', '2021-10-10'],
+        ['month', '2021-10-01', '2021-10-31'],
+        ['quarter', '2021-10-01', '2021-12-31'],
+        ['year', '2021-01-01', '2021-12-31'],
+    ])('should build relative date range ("%s")', (range, start, end) => {
+        const dateRange = DateRange.buildRelative(range as moment.unitOfTime.StartOf);
+        testDateRange(dateRange, start, end);
+    });
+});
+
+describe('Date Parser - correct delta for next & last month & quarter (Today is 2021-04-03)', () => {
+    beforeAll(() => {
+        jest.useFakeTimers();
+        jest.setSystemTime(new Date(2021, 4 - 1, 3)); // 2021-04-03
+    });
+
+    afterAll(() => {
+        jest.useRealTimers();
+    });
+
+    it('should have correct date range after going to next or previous range', () => {
+        // The purpose of this test to make adding and subtracting ranges intelligent
+        // Subtracting weeks is simple, because their length is always 7 day
+        const lastWeek = DateRange.buildRelative('week');
+        lastWeek.moveToPrevious('week');
+        testDateRange(lastWeek, '2021-03-22', '2021-03-28');
+
+        const nextWeek = DateRange.buildRelative('week');
+        nextWeek.moveToNext('week');
+        testDateRange(nextWeek, '2021-04-05', '2021-04-11');
+
+        // Months' lengths in days differ (28/30/31)
+        const lastMonth = DateRange.buildRelative('month');
+        lastMonth.moveToPrevious('month');
+        testDateRange(lastMonth, '2021-03-01', '2021-03-31');
+
+        const nextMonth = DateRange.buildRelative('month');
+        nextMonth.moveToNext('month');
+        testDateRange(nextMonth, '2021-05-01', '2021-05-31');
+
+        // Q1 and Q2 && Q3 and Q4 lengths differ because of the months
+        const lastQuarter = DateRange.buildRelative('quarter');
+        lastQuarter.moveToPrevious('quarter');
+        testDateRange(lastQuarter, '2021-01-01', '2021-03-31');
+
+        // Q2 and Q3 have same length in days
+        const nextQuarter = DateRange.buildRelative('quarter');
+        nextQuarter.moveToNext('quarter');
+        testDateRange(nextQuarter, '2021-07-01', '2021-09-30');
+
+        // Leap year - 366 days, lengths differ
+        // Leap year happens every 4 years
+        const lastYear = DateRange.buildRelative('year');
+        lastYear.moveToPrevious('year');
+        testDateRange(lastYear, '2020-01-01', '2020-12-31');
+
+        // Normal year - 365 days
+        const nextYear = DateRange.buildRelative('year');
+        nextYear.moveToNext('year');
+        testDateRange(nextYear, '2022-01-01', '2022-12-31');
+    });
+});
+
+describe('DateRange - range validity', () => {
+    it('should build a valid date range', () => {
+        const dateRange = new DateRange(moment('0000-01-01'), moment('2023-12-31'));
+        expect(dateRange.start).toBeDefined();
+        expect(dateRange.end).toBeDefined();
+        expect(dateRange.start.isValid()).toEqual(true);
+        expect(dateRange.end.isValid()).toEqual(true);
+        expect(dateRange.isValid()).toEqual(true);
+    });
+
+    it('should build an invalid range', () => {
+        // Both moments are invalid
+        const dateRange = DateRange.buildInvalid();
+        expect(dateRange.start).toBeDefined();
+        expect(dateRange.end).toBeDefined();
+        expect(dateRange.start.isValid()).toEqual(false);
+        expect(dateRange.end.isValid()).toEqual(false);
+        expect(dateRange.isValid()).toEqual(false);
+    });
+    it('should detect an invalid range', () => {
+        // At least one the dates is invalid
+        const dateRange1 = new DateRange(moment(), moment());
+        dateRange1.start = moment.invalid();
+        expect(dateRange1.start).toBeDefined();
+        expect(dateRange1.end).toBeDefined();
+        expect(dateRange1.start.isValid()).toEqual(false);
+        expect(dateRange1.end.isValid()).toEqual(true);
+        expect(dateRange1.isValid()).toEqual(false);
+
+        const dateRange2 = new DateRange(moment(), moment());
+        dateRange2.end = moment.invalid();
+        expect(dateRange2.start).toBeDefined();
+        expect(dateRange2.end).toBeDefined();
+        expect(dateRange2.start.isValid()).toEqual(true);
+        expect(dateRange2.end.isValid()).toEqual(false);
+        expect(dateRange2.isValid()).toEqual(false);
+    });
+});