Repository: obsidian-tasks-group/obsidian-tasks (ts)

Issue:
Queries with weeks, months, quarters, year halves and years
### ⚠️ Please check that this feature request hasn't been suggested before.

- [X] I searched previous [Ideas in Discussions](https://github.com/obsidian-tasks-group/obsidian-tasks/discussions/categories/ideas-any-new-feature-requests-go-in-issues-please?discussions_q=category%3A%22Ideas%3A+Any+New+Feature+Requests+go+in+Issues+please%22+-label%3A%22status%3A+released%22+-label%3Aduplicate+sort%3Atop) didn't find any similar feature requests.
- [X] I searched previous [Issues](https://github.com/obsidian-tasks-group/obsidian-tasks/issues?q=is%3Aopen+is%3Aissue+label%3A%22type%3A+enhancement%22+sort%3Acomments-desc) didn't find any similar feature requests.

### 🔖 Feature description

For my task management I'm was trying to switch to kanban approach, but with lists of tasks instead of cards. I was hoping to use Canvas + Tasks, but switched to Canvas + Dataview (DQL TASKS). However it doesn't look as good as Tasks plugin because there is not much configurability of the task output in DQL TASKS.

So a feature where I can isolate tasks due/scheduled/happening/done in a particular week (`2023-W01`) or month (`2023-01`) would be useful for me - I could create a kanban for each time period. Maybe it could be worth to extend it to quarter 2023-Q[1-4], semester 2023-H[1-2], year 2023.

### ✔️ Solution

Date filters such as `happens`, `scheduled`, `due`, `done` are now accepting  the following structure:
`<dateFilter> (before|after|on) <date>`.

I suggest to also add 
`<dateFilter> (in) <dateRange>` where `dateRange` can be:
- `YYYY` for a year
-  `YYYY-H[1, 2]` for half a year
-  `YYYY-Q[1-4]` for a quarter
-  `YYYY-[01-12]` for a month 
-  `YYYY-W[01-52/53]` for a week (some years have 53 weeks)

### ❓ Alternatives

Set filter periods explicitly eg
```
due after 2023-01-01
due before 2023-01-31
```
creating the dates with something like Templater, but meh... =)

### 📝 Additional Context

Issue coming from https://github.com/obsidian-tasks-group/obsidian-tasks/issues/1582

Pull request description:
feat: Search for numbered week, month, quarter & year ranges
<!--- Provide a general summary of your changes in the Title above -->

# Description

<!--- Describe your changes in detail -->

## Motivation and Context

Fixes #1621 by proposing queries with specific date ranges eg `2023`, `2023-Q1`, `2023-03`, `2023-W09`.

## How has this been tested?

## 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)
- [x] **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)
- [ ] **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))

Internal changes:

- [ ] **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`.
- [x] 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.
-->

- [ ] My contribution follow this project's [contributing guide](https://publish.obsidian.md/tasks-contributing)
- [ ] I agree to follow this project's [Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md)


Patch:
diff --git a/src/Query/DateParser.ts b/src/Query/DateParser.ts
index 5b01b858..2847bd8e 100644
--- a/src/Query/DateParser.ts
+++ b/src/Query/DateParser.ts
@@ -20,22 +20,34 @@ export class DateParser {
      * @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] {
-        const relativeDateRange = DateParser.parseRelativeDateRange(input);
-        if (relativeDateRange !== undefined) {
-            return relativeDateRange;
+        const dateRangeParsers = [
+            // Try parsing a relative date range like 'current month'
+            DateParser.parseRelativeDateRange,
+            // Try '2022-W10' otherwise
+            DateParser.parseSpecificDateRange,
+            // If previous failed, fallback on absolute date range with chrono
+            DateParser.parseAbsoluteDateRange,
+        ];
+
+        for (const parser of dateRangeParsers) {
+            const parsedDateRange = parser(input);
+            if (parsedDateRange !== undefined) {
+                return parsedDateRange;
+            }
         }
 
-        // If relative date range was not parsed, fallback on absolute date range with chrono
-        return DateParser.parseAbsoluteDateRange(input);
+        // If nothing worked return and invalid date range
+        return [moment.invalid(), moment.invalid()];
     }
 
-    private static parseAbsoluteDateRange(input: string): [moment.Moment, moment.Moment] {
+    private static parseAbsoluteDateRange(input: string): [moment.Moment, moment.Moment] | undefined {
         const result = chrono.parse(input, undefined, {
             forwardDate: true,
         });
 
+        // Check chrono parsing
         if (result.length === 0) {
-            return [moment.invalid(), moment.invalid()];
+            return undefined;
         }
 
         const startDate = result[0].start;
@@ -48,9 +60,7 @@ export class DateParser {
             absoluteDateRange = [end, start];
         }
 
-        // Dates shall be at midnight eg 00:00
-        absoluteDateRange.forEach((d) => d.startOf('day'));
-        return absoluteDateRange;
+        return DateParser.setDateRangeToStartOfDay(absoluteDateRange);
     }
 
     private static parseRelativeDateRange(input: string): [moment.Moment, moment.Moment] | undefined {
@@ -75,12 +85,39 @@ export class DateParser {
             const unitOfTime = range === 'week' ? 'isoWeek' : (range as moment.unitOfTime.DurationConstructor);
             dateRange = [dateRange[0].startOf(unitOfTime), dateRange[1].endOf(unitOfTime)];
 
-            // Dates shall be at midnight eg 00:00
-            dateRange.forEach((d) => d.startOf('day'));
+            return DateParser.setDateRangeToStartOfDay(dateRange);
+        }
+
+        return undefined;
+    }
+
+    private static parseSpecificDateRange(input: string): [moment.Moment, moment.Moment] | undefined {
+        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'],
+            [/^\s*[0-9]{4}-[0-9]{2}\s*$/, 'YYYY-MM', 'month'],
+            [/^\s*[0-9]{4}-W[0-9]{2}\s*$/, 'YYYY-WW', 'isoWeek'],
+        ];
 
-            return dateRange;
+        for (const [regexp, format, unit] 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);
+            }
         }
 
         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;
+    }
 }
diff --git a/src/Query/Explain/Explanation.ts b/src/Query/Explain/Explanation.ts
index 9f8d8b68..880019d2 100644
--- a/src/Query/Explain/Explanation.ts
+++ b/src/Query/Explain/Explanation.ts
@@ -61,14 +61,24 @@ export class Explanation {
             return currentIndentation + this.description;
         }
 
-        // We have children, so concatenate them together
-        let result = currentIndentation + `${this.symbol}`;
-        if (this.children.length > 1) {
-            // The descriptions like 'All of', 'None of' are one really meaningful
-            // if there is more than one filter. Otherwise, they are just confusing.
-            result += ` (${this.description})`;
+        let result = currentIndentation;
+
+        if (this.symbol === '') {
+            // If the symbol was not set, add the description and just indent
+            result += this.description;
+        } else {
+            // Otherwise we need detailed explanation with logic
+            result += this.symbol;
+
+            // We have children, so concatenate them together
+            if (this.children.length > 1) {
+                // The descriptions like 'All of', 'None of' are one really meaningful
+                // if there is more than one filter. Otherwise, they are just confusing.
+                result += ` (${this.description})`;
+            }
+            result += ':';
         }
-        result += ':';
+
         const newIndentation = currentIndentation + '  ';
         for (let i = 0; i < this.children.length; i++) {
             result += `\n${this.children[i].asString(newIndentation)}`;
diff --git a/src/Query/Filter/DateField.ts b/src/Query/Filter/DateField.ts
index b792996c..9b3c9aab 100644
--- a/src/Query/Filter/DateField.ts
+++ b/src/Query/Filter/DateField.ts
@@ -65,7 +65,7 @@ export abstract class DateField extends Field {
                     this.filterResultIfFieldMissing(),
                     fieldDates,
                 );
-                result.filter = new Filter(line, filterFunction, new Explanation(explanation));
+                result.filter = new Filter(line, filterFunction, explanation);
             }
         } else {
             result.error = 'do not understand query filter (' + this.fieldName() + ' date)';
@@ -119,7 +119,7 @@ export abstract class DateField extends Field {
     public abstract date(task: Task): Moment | null;
 
     /**
-     * Construct a string used to explain a date-based filter
+     * Constructs an Explanation for a date-based filter
      * @param fieldName - for example, 'due'
      * @param fieldKeyword - one of the keywords like 'before' or 'after'
      * @param filterResultIfFieldMissing - whether the search matches tasks without the requested date value
@@ -130,7 +130,7 @@ export abstract class DateField extends Field {
         fieldKeyword: string,
         filterResultIfFieldMissing: boolean,
         filterDates: [moment.Moment, moment.Moment],
-    ): string {
+    ): Explanation {
         let relationship;
         // Example of formatted date: '2024-01-02 (Tuesday 2nd January 2024)'
         const dateFormat = 'YYYY-MM-DD (dddd Do MMMM YYYY)';
@@ -149,19 +149,31 @@ export abstract class DateField extends Field {
                     relationship = 'on';
                     explanationDates = filterDates[0].format(dateFormat);
                 } else {
-                    relationship = 'between';
-                    explanationDates = `${filterDates[0].format(dateFormat)} and ${filterDates[1].format(
-                        dateFormat,
-                    )} inclusive`;
+                    // This is a special case where a multi-line explanation has to be built
+                    // All other cases need only one line
+                    const firstLine = `${fieldName} date is between:`;
+
+                    // Consecutive lines
+                    const subExplanations = [
+                        new Explanation(`${filterDates[0].format(dateFormat)} and`),
+                        new Explanation(`${filterDates[1].format(dateFormat)} inclusive`),
+                    ];
+
+                    // Optional line for StartDateField (so far)
+                    if (filterResultIfFieldMissing) {
+                        subExplanations.push(new Explanation(`OR no ${fieldName} date`));
+                    }
+
+                    return new Explanation(firstLine, subExplanations);
                 }
                 break;
         }
 
-        let result = `${fieldName} date is ${relationship} ${explanationDates}`;
+        let oneLineExplanation = `${fieldName} date is ${relationship} ${explanationDates}`;
         if (filterResultIfFieldMissing) {
-            result += ` OR no ${fieldName} date`;
+            oneLineExplanation += ` OR no ${fieldName} date`;
         }
-        return result;
+        return new Explanation(oneLineExplanation);
     }
 
     protected fieldNameForExplanation() {


Test patch:
diff --git a/tests/Query/DateParser.test.ts b/tests/Query/DateParser.test.ts
index d467371b..5ecd3cca 100644
--- a/tests/Query/DateParser.test.ts
+++ b/tests/Query/DateParser.test.ts
@@ -144,3 +144,75 @@ describe('Date Parser - correct delta for next & last month & quarter (Today is
         // because the length of Q2 in days is same as Q3.
     });
 });
+
+describe('DateParser - specific date ranges', () => {
+    it('should return dates for specific ranges', () => {
+        // Week (53 in a year)
+        testParsingDateRange('2020-W53', '2020-12-28', '2021-01-03');
+        testParsingDateRange('2021-W01', '2021-01-04', '2021-01-10');
+
+        // Week (52 in a year)
+        testParsingDateRange('2018-W52', '2018-12-24', '2018-12-30');
+        testParsingDateRange('2019-W01', '2018-12-31', '2019-01-06');
+        testParsingDateRange('2019-W02', '2019-01-07', '2019-01-13');
+
+        // Month
+        testParsingDateRange('2002-02', '2002-02-01', '2002-02-28');
+        testParsingDateRange('2013-06', '2013-06-01', '2013-06-30');
+        testParsingDateRange('2017-12', '2017-12-01', '2017-12-31');
+        testParsingDateRange('2024-02', '2024-02-01', '2024-02-29');
+
+        // Quarter
+        testParsingDateRange('2007-Q1', '2007-01-01', '2007-03-31');
+
+        // Year
+        testParsingDateRange('1996', '1996-01-01', '1996-12-31');
+        testParsingDateRange('2022', '2022-01-01', '2022-12-31');
+    });
+
+    it('should allow the range text to be padded with spaces', () => {
+        testParsingDateRange('2022 ', '2022-01-01', '2022-12-31');
+        testParsingDateRange(' 2023-Q3', '2023-07-01', '2023-09-30');
+        testParsingDateRange('  2021-W30', '2021-07-26', '2021-08-01');
+        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', () => {
+        // 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.
+        // The correct last week of a year is tested in a previous test
+        testParsingDateRange('2020-W54', 'Invalid date', 'Invalid date');
+        testParsingDateRange('2018-W53', 'Invalid date', 'Invalid date');
+
+        // Week (Wrong number)
+        testParsingDateRange('2021-W0', 'Invalid date', 'Invalid date');
+        testParsingDateRange('2021-W00', 'Invalid date', 'Invalid date');
+
+        // Quarter
+        testParsingDateRange('2023-Q0', 'Invalid date', 'Invalid date');
+        testParsingDateRange('2023-Q5', 'Invalid date', 'Invalid date');
+
+        // Month
+        testParsingDateRange('2017-13', 'Invalid date', 'Invalid date');
+        testParsingDateRange('2023-14', 'Invalid date', 'Invalid date');
+        testParsingDateRange('2023-00', 'Invalid date', 'Invalid date');
+
+        // Year
+        testParsingDateRange('20167', 'Invalid date', 'Invalid date');
+    });
+});
diff --git a/tests/Query/Explain/Explain.test.ts b/tests/Query/Explain/Explain.test.ts
index 70f85e1f..abc8b973 100644
--- a/tests/Query/Explain/Explain.test.ts
+++ b/tests/Query/Explain/Explain.test.ts
@@ -59,4 +59,21 @@ describe('Explain', () => {
     x2 includes D`;
         expect(and.asString()).toEqual(expected);
     });
+
+    it('Explains a nested non-boolean combination (Indent only depending on children)', () => {
+        const firstLine = 'Things that happen when you write code:';
+        const bottomLevelExplanation = new Explanation('or foobar sauce');
+        const nestedExplanations = [
+            new Explanation('you eat apple sauce', [bottomLevelExplanation]),
+            new Explanation('you enjoy bar'),
+            new Explanation('you love foo'),
+        ];
+        const explanation = new Explanation(firstLine, nestedExplanations);
+        const expected = `Things that happen when you write code:
+  you eat apple sauce
+    or foobar sauce
+  you enjoy bar
+  you love foo`;
+        expect(explanation.asString()).toEqual(expected);
+    });
 });
diff --git a/tests/Query/Filter/DueDateField.test.due_date approval tests.approved.md b/tests/Query/Filter/DueDateField.test.due_date approval tests.approved.md
index e942e84e..0e653bad 100644
--- a/tests/Query/Filter/DueDateField.test.due_date approval tests.approved.md
+++ b/tests/Query/Filter/DueDateField.test.due_date approval tests.approved.md
@@ -1,7 +1,7 @@
 | date / keyword | last week | this week | next week | 2023-02-09 | 2023-02-07 2023-02-11 |
 | ----- | ----- | ----- | ----- | ----- | ----- |
 | before  | due before last week =><br>  due date is before 2023-01-30 (Monday 30th January 2023)<br> | due before this week =><br>  due date is before 2023-02-06 (Monday 6th February 2023)<br> | due before next week =><br>  due date is before 2023-02-13 (Monday 13th February 2023)<br> | due before 2023-02-09 =><br>  due date is before 2023-02-09 (Thursday 9th February 2023)<br> | due before 2023-02-07 2023-02-11 =><br>  due date is before 2023-02-07 (Tuesday 7th February 2023)<br> |
-| on  | due on last week =><br>  due date is between 2023-01-30 (Monday 30th January 2023) and 2023-02-05 (Sunday 5th February 2023) inclusive<br> | due on this week =><br>  due date is between 2023-02-06 (Monday 6th February 2023) and 2023-02-12 (Sunday 12th February 2023) inclusive<br> | due on next week =><br>  due date is between 2023-02-13 (Monday 13th February 2023) and 2023-02-19 (Sunday 19th February 2023) inclusive<br> | due on 2023-02-09 =><br>  due date is on 2023-02-09 (Thursday 9th February 2023)<br> | due on 2023-02-07 2023-02-11 =><br>  due date is between 2023-02-07 (Tuesday 7th February 2023) and 2023-02-11 (Saturday 11th February 2023) inclusive<br> |
+| on  | due on last week =><br>  due date is between:<br>    2023-01-30 (Monday 30th January 2023) and<br>    2023-02-05 (Sunday 5th February 2023) inclusive<br> | due on this week =><br>  due date is between:<br>    2023-02-06 (Monday 6th February 2023) and<br>    2023-02-12 (Sunday 12th February 2023) inclusive<br> | due on next week =><br>  due date is between:<br>    2023-02-13 (Monday 13th February 2023) and<br>    2023-02-19 (Sunday 19th February 2023) inclusive<br> | due on 2023-02-09 =><br>  due date is on 2023-02-09 (Thursday 9th February 2023)<br> | due on 2023-02-07 2023-02-11 =><br>  due date is between:<br>    2023-02-07 (Tuesday 7th February 2023) and<br>    2023-02-11 (Saturday 11th February 2023) inclusive<br> |
 | after  | due after last week =><br>  due date is after 2023-02-05 (Sunday 5th February 2023)<br> | due after this week =><br>  due date is after 2023-02-12 (Sunday 12th February 2023)<br> | due after next week =><br>  due date is after 2023-02-19 (Sunday 19th February 2023)<br> | due after 2023-02-09 =><br>  due date is after 2023-02-09 (Thursday 9th February 2023)<br> | due after 2023-02-07 2023-02-11 =><br>  due date is after 2023-02-11 (Saturday 11th February 2023)<br> |
-| in  | due in last week =><br>  due date is between 2023-01-30 (Monday 30th January 2023) and 2023-02-05 (Sunday 5th February 2023) inclusive<br> | due in this week =><br>  due date is between 2023-02-06 (Monday 6th February 2023) and 2023-02-12 (Sunday 12th February 2023) inclusive<br> | due in next week =><br>  due date is between 2023-02-13 (Monday 13th February 2023) and 2023-02-19 (Sunday 19th February 2023) inclusive<br> | due in 2023-02-09 =><br>  due date is on 2023-02-09 (Thursday 9th February 2023)<br> | due in 2023-02-07 2023-02-11 =><br>  due date is between 2023-02-07 (Tuesday 7th February 2023) and 2023-02-11 (Saturday 11th February 2023) inclusive<br> |
-|  | due last week =><br>  due date is between 2023-01-30 (Monday 30th January 2023) and 2023-02-05 (Sunday 5th February 2023) inclusive<br> | due this week =><br>  due date is between 2023-02-06 (Monday 6th February 2023) and 2023-02-12 (Sunday 12th February 2023) inclusive<br> | due next week =><br>  due date is between 2023-02-13 (Monday 13th February 2023) and 2023-02-19 (Sunday 19th February 2023) inclusive<br> | due 2023-02-09 =><br>  due date is on 2023-02-09 (Thursday 9th February 2023)<br> | due 2023-02-07 2023-02-11 =><br>  due date is between 2023-02-07 (Tuesday 7th February 2023) and 2023-02-11 (Saturday 11th February 2023) inclusive<br> |
+| in  | due in last week =><br>  due date is between:<br>    2023-01-30 (Monday 30th January 2023) and<br>    2023-02-05 (Sunday 5th February 2023) inclusive<br> | due in this week =><br>  due date is between:<br>    2023-02-06 (Monday 6th February 2023) and<br>    2023-02-12 (Sunday 12th February 2023) inclusive<br> | due in next week =><br>  due date is between:<br>    2023-02-13 (Monday 13th February 2023) and<br>    2023-02-19 (Sunday 19th February 2023) inclusive<br> | due in 2023-02-09 =><br>  due date is on 2023-02-09 (Thursday 9th February 2023)<br> | due in 2023-02-07 2023-02-11 =><br>  due date is between:<br>    2023-02-07 (Tuesday 7th February 2023) and<br>    2023-02-11 (Saturday 11th February 2023) inclusive<br> |
+|  | due last week =><br>  due date is between:<br>    2023-01-30 (Monday 30th January 2023) and<br>    2023-02-05 (Sunday 5th February 2023) inclusive<br> | due this week =><br>  due date is between:<br>    2023-02-06 (Monday 6th February 2023) and<br>    2023-02-12 (Sunday 12th February 2023) inclusive<br> | due next week =><br>  due date is between:<br>    2023-02-13 (Monday 13th February 2023) and<br>    2023-02-19 (Sunday 19th February 2023) inclusive<br> | due 2023-02-09 =><br>  due date is on 2023-02-09 (Thursday 9th February 2023)<br> | due 2023-02-07 2023-02-11 =><br>  due date is between:<br>    2023-02-07 (Tuesday 7th February 2023) and<br>    2023-02-11 (Saturday 11th February 2023) inclusive<br> |
diff --git a/tests/Query/Filter/DueDateField.test.ts b/tests/Query/Filter/DueDateField.test.ts
index e7524e97..7102e3e5 100644
--- a/tests/Query/Filter/DueDateField.test.ts
+++ b/tests/Query/Filter/DueDateField.test.ts
@@ -106,6 +106,56 @@ describe('due date', () => {
     });
 });
 
+describe('due date (specific ranges)', () => {
+    it('due in specific range (year)', () => {
+        // Arrange
+        const filter = new DueDateField().createFilterOrErrorMessage('due 2022');
+
+        // Act, Assert
+        testTaskFilterForTaskWithDueDate(filter, null, false);
+        testTaskFilterForTaskWithDueDate(filter, '2021-12-31', false);
+        testTaskFilterForTaskWithDueDate(filter, '2022-01-01', true);
+        testTaskFilterForTaskWithDueDate(filter, '2022-12-31', true);
+        testTaskFilterForTaskWithDueDate(filter, '2023-01-01', false);
+    });
+
+    it('due in specific range (quarter)', () => {
+        // Arrange
+        const filter = new DueDateField().createFilterOrErrorMessage('due 2017-Q3');
+
+        // Act, Assert
+        testTaskFilterForTaskWithDueDate(filter, null, false);
+        testTaskFilterForTaskWithDueDate(filter, '2017-06-30', false);
+        testTaskFilterForTaskWithDueDate(filter, '2017-07-01', true);
+        testTaskFilterForTaskWithDueDate(filter, '2017-09-30', true);
+        testTaskFilterForTaskWithDueDate(filter, '2017-10-01', false);
+    });
+
+    it('due in specific range (month)', () => {
+        // Arrange
+        const filter = new DueDateField().createFilterOrErrorMessage('due 2020-03');
+
+        // Act, Assert
+        testTaskFilterForTaskWithDueDate(filter, null, false);
+        testTaskFilterForTaskWithDueDate(filter, '2020-02-28', false);
+        testTaskFilterForTaskWithDueDate(filter, '2020-03-01', true);
+        testTaskFilterForTaskWithDueDate(filter, '2020-03-31', true);
+        testTaskFilterForTaskWithDueDate(filter, '2020-04-01', false);
+    });
+
+    it('due in specific range (week)', () => {
+        // Arrange
+        const filter = new DueDateField().createFilterOrErrorMessage('due 2023-W09');
+
+        // Act, Assert
+        testTaskFilterForTaskWithDueDate(filter, null, false);
+        testTaskFilterForTaskWithDueDate(filter, '2023-02-26', false);
+        testTaskFilterForTaskWithDueDate(filter, '2023-02-27', true);
+        testTaskFilterForTaskWithDueDate(filter, '2023-03-05', true);
+        testTaskFilterForTaskWithDueDate(filter, '2023-03-06', false);
+    });
+});
+
 describe('due date (error & corner cases)', () => {
     it('invalid due date', () => {
         // Arrange
@@ -317,9 +367,13 @@ describe('due date in relative date range (Today is 2023-02-28)', () => {
         const filterIn = new DueDateField().createFilterOrErrorMessage(`due in this ${range}`);
         const filterEmpty = new DueDateField().createFilterOrErrorMessage(`due this ${range}`);
 
-        expect(filterOn).toHaveExplanation(`due date is between ${dateStart} and ${dateEnd} inclusive`);
-        expect(filterIn).toHaveExplanation(`due date is between ${dateStart} and ${dateEnd} inclusive`);
-        expect(filterEmpty).toHaveExplanation(`due date is between ${dateStart} and ${dateEnd} inclusive`);
+        const expectedExplanation = `due date is between:
+  ${dateStart} and
+  ${dateEnd} inclusive`;
+
+        expect(filterOn).toHaveExplanation(expectedExplanation);
+        expect(filterIn).toHaveExplanation(expectedExplanation);
+        expect(filterEmpty).toHaveExplanation(expectedExplanation);
     });
 });
 
@@ -393,7 +447,9 @@ describe('explain due date queries', () => {
     it('should explain date range', () => {
         const filterOrMessage = new DueDateField().createFilterOrErrorMessage('due 2022-11-25 2023-01-17');
         expect(filterOrMessage).toHaveExplanation(
-            'due date is between 2022-11-25 (Friday 25th November 2022) and 2023-01-17 (Tuesday 17th January 2023) inclusive',
+            `due date is between:
+  2022-11-25 (Friday 25th November 2022) and
+  2023-01-17 (Tuesday 17th January 2023) inclusive`,
         );
     });
 });
diff --git a/tests/Query/Filter/StartDateField.test.ts b/tests/Query/Filter/StartDateField.test.ts
index aabab759..33c3fe2a 100644
--- a/tests/Query/Filter/StartDateField.test.ts
+++ b/tests/Query/Filter/StartDateField.test.ts
@@ -16,6 +16,18 @@ describe('explain start date queries', () => {
         );
     });
 
+    it('should explain absolute date range', () => {
+        const filterOrMessage = new StartDateField().createFilterOrErrorMessage('starts 2023-03-01 2023-03-03');
+        // Full date range testing done in DueDateField
+        // But StartDateField is so far the only Field with 'OR no start date' in explanation
+        expect(filterOrMessage).toHaveExplanation(
+            `start date is between:
+  2023-03-01 (Wednesday 1st March 2023) and
+  2023-03-03 (Friday 3rd March 2023) inclusive
+  OR no start date`,
+        );
+    });
+
     it('implicit "on" gets added to explanation, and it is clear that start date is optional', () => {
         const filterOrMessage = new StartDateField().createFilterOrErrorMessage('starts 2023-01-02');
         expect(filterOrMessage).toHaveExplanation(