Repository: obsidian-tasks-group/obsidian-tasks
Pull request #1941 (2023-05-09 09:29:50)

feat: Add 'sort by recurring' instruction
<!--- Provide a general summary of your changes in the Title above -->

# Description

Add `sort by recurring` instruction.

Note: recurring tasks sort before non-recurring ones.

## Motivation and Context

I wanted to be able to prioritise dealing with non-recurring tasks before recurring ones - which can now be done with:

 `sort by recurring reverse`

## How has this been tested?

<!--- Please describe in detail how you tested your changes. -->
<!--- Include details of your testing environment, tests ran to see how -->
<!--- your change affects other areas of the code, etc. -->

Adding new tests.

## 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:

- [x] **New feature** (prefix: `feat` - non-breaking change which adds functionality)
- [x] **Documentation** (prefix: `docs` - improvements to any documentation content **for users**)

Internal changes:

- [x] **Tests** (prefix: `test` - additions and improvements to unit tests and the smoke tests)

## 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.
- [x] 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)



Patch:
diff --git a/docs/Queries/Sorting.md b/docs/Queries/Sorting.md
index 8d252de1..0d91095c 100644
--- a/docs/Queries/Sorting.md
+++ b/docs/Queries/Sorting.md
@@ -59,6 +59,7 @@ For more information, including adding your own customised statuses, see [[Statu
 1. `description` (the description of the task)
 1. `priority` (priority of the task; "low" is below "none": [[Priority|priorities]])
 1. `urgency` ([[Urgency|urgency]])
+1. `recurring` (recurring tasks sort before non-recurring ones: [[Recurring Tasks]])
 1. `tag` (the description of the task)
 
 ## Multiple sort criteria
diff --git a/docs/Quick Reference.md b/docs/Quick Reference.md
index c7900e45..b9c757eb 100644
--- a/docs/Quick Reference.md
+++ b/docs/Quick Reference.md
@@ -19,13 +19,13 @@ This table summarizes the filters and other options available inside a `tasks` b
 | `scheduled (before, after, on) <date>`<br>`scheduled (before, after, in) ...`<br>`... YYYY-MM-DD YYYY-MM-DD`<br>`... (last, this, next) (week, month, quarter, year)`<br>`... (YYYY-Www,YYYY-mm, YYYY-Qq, YYYY)`<br>`has scheduled date`<br>`no  scheduled date`<br>`scheduled date is invalid` | `sort by scheduled`                         | `group by scheduled`   | `hide scheduled date`  |
 | `due (before, after, on) <date>`<br>`due (before, after, in) ...`<br>`... YYYY-MM-DD YYYY-MM-DD`<br>`... (last, this, next) (week, month, quarter, year)`<br>`... (YYYY-Www,YYYY-mm, YYYY-Qq, YYYY)`<br>`has due date`<br>`no  due date`<br>`due date is invalid`                               | `sort by due`                               | `group by due`         | `hide due date`        |
 | `happens (before, after, on) <date>`<br>`happens (before, after, in) ...`<br>`... YYYY-MM-DD YYYY-MM-DD`<br>`... (last, this, next) (week, month, quarter, year)`<br>`... (YYYY-Www,YYYY-mm, YYYY-Qq, YYYY)`<br>`has happens date`<br>`no  happens date`                                        | `sort by happens`                           | `group by happens`     |                        |
-| `is recurring`<br>`is not recurring`                                                                                                                                                                                                                                                            |                                             | `group by recurring`   |                        |
+| `is recurring`<br>`is not recurring`                                                                                                                                                                                                                                                            | `sort by recurring`                         | `group by recurring`   |                        |
 | `recurrence (includes, does not include) <string>`<br>`recurrence (regex matches, regex does not match) /regex/i`                                                                                                                                                                               |                                             | `group by recurrence`  | `hide recurrence rule` |
 | `priority is (above, below, not)? (low, none, medium, high)`                                                                                                                                                                                                                                    | `sort by priority`                          | `group by priority`    | `hide priority`        |
 |                                                                                                                                                                                                                                                                                                 | `sort by urgency`                           |                        | `show urgency`         |
 | `path (includes, does not include) <path>`<br>`path (regex matches, regex does not match) /regex/i`                                                                                                                                                                                             | `sort by path`                              | `group by path`        |                        |
 | `root (includes, does not include) <root>`<br>`root (regex matches, regex does not match) /regex/i`                                                                                                                                                                                             |                                             | `group by root`        |                        |
-| `folder (includes, does not include) <folder>`<br>`folder (regex matches, regex does not match) /regex/i`                                                                                                                                                                                             |                                             | `group by folder`      |                        |
+| `folder (includes, does not include) <folder>`<br>`folder (regex matches, regex does not match) /regex/i`                                                                                                                                                                                       |                                             | `group by folder`      |                        |
 | `filename (includes, does not include) <filename>`<br>`filename (regex matches, regex does not match) /regex/i`                                                                                                                                                                                 | `sort by filename`                          | `group by filename`    |                        |
 | `heading (includes, does not include) <string>`<br>`heading (regex matches, regex does not match) /regex/i`                                                                                                                                                                                     | `sort by heading`                           | `group by heading`     |                        |
 |                                                                                                                                                                                                                                                                                                 |                                             | `group by backlink`    | `hide backlink`        |
diff --git a/src/Query/Filter/RecurringField.ts b/src/Query/Filter/RecurringField.ts
index 548180dd..08057ecf 100644
--- a/src/Query/Filter/RecurringField.ts
+++ b/src/Query/Filter/RecurringField.ts
@@ -1,5 +1,6 @@
 import type { Task } from '../../Task';
 import type { GrouperFunction } from '../Grouper';
+import type { Comparator } from '../Sorter';
 import { FilterInstructionsBasedField } from './FilterInstructionsBasedField';
 
 export class RecurringField extends FilterInstructionsBasedField {
@@ -13,6 +14,31 @@ export class RecurringField extends FilterInstructionsBasedField {
         return 'recurring';
     }
 
+    // -----------------------------------------------------------------------------------------------------------------
+    // Sorting
+    // -----------------------------------------------------------------------------------------------------------------
+
+    public supportsSorting(): boolean {
+        return true;
+    }
+
+    comparator(): Comparator {
+        // Recurring tasks sort before non-recurring ones
+        return (a: Task, b: Task) => {
+            if (a.recurrence !== null && b.recurrence === null) {
+                return -1;
+            } else if (a.recurrence === null && b.recurrence !== null) {
+                return 1;
+            } else {
+                return 0;
+            }
+        };
+    }
+
+    // -----------------------------------------------------------------------------------------------------------------
+    // Grouping
+    // -----------------------------------------------------------------------------------------------------------------
+
     public supportsGrouping(): boolean {
         return true;
     }


Test patch:
diff --git a/tests/Query.test.ts b/tests/Query.test.ts
index 54e8d0c1..dbc032c5 100644
--- a/tests/Query.test.ts
+++ b/tests/Query.test.ts
@@ -168,6 +168,7 @@ describe('Query parsing', () => {
             'sort by path',
             'sort by priority reverse',
             'sort by priority',
+            'sort by recurring',
             'sort by scheduled reverse',
             'sort by scheduled',
             'sort by start reverse',
diff --git a/tests/Query/Filter/RecurringField.test.ts b/tests/Query/Filter/RecurringField.test.ts
index 81bda6f7..56dc1cd0 100644
--- a/tests/Query/Filter/RecurringField.test.ts
+++ b/tests/Query/Filter/RecurringField.test.ts
@@ -6,6 +6,13 @@ import { RecurringField } from '../../../src/Query/Filter/RecurringField';
 import type { FilterOrErrorMessage } from '../../../src/Query/Filter/Filter';
 import { testTaskFilter } from '../../TestingTools/FilterTestHelpers';
 import { fromLine } from '../../TestHelpers';
+import { TaskBuilder } from '../../TestingTools/TaskBuilder';
+import {
+    expectTaskComparesAfter,
+    expectTaskComparesBefore,
+    expectTaskComparesEqual,
+} from '../../CustomMatchers/CustomMatchersForSorting';
+import { RecurrenceBuilder } from '../../TestingTools/RecurrenceBuilder';
 
 window.moment = moment;
 
@@ -41,6 +48,44 @@ describe('recurring', () => {
     });
 });
 
+describe('sorting by recurring', () => {
+    const recurrence = new RecurrenceBuilder().rule('every week when done').startDate('2022-07-14').build();
+    const recurring = new TaskBuilder().recurrence(recurrence).build();
+    const nonRecurring = new TaskBuilder().recurrence(null).build();
+
+    it('supports Field sorting methods correctly', () => {
+        const field = new RecurringField();
+        expect(field.supportsSorting()).toEqual(true);
+    });
+
+    it('parses sort by recurrence', () => {
+        const field = new RecurringField();
+        expect(field.parseSortLine('sort by recurring')).not.toBeNull();
+    });
+
+    it('sort by due', () => {
+        // Arrange
+        const sorter = new RecurringField().createNormalSorter();
+
+        // Assert
+        expectTaskComparesBefore(sorter, recurring, nonRecurring);
+        expectTaskComparesAfter(sorter, nonRecurring, recurring);
+        expectTaskComparesEqual(sorter, nonRecurring, nonRecurring);
+        expectTaskComparesEqual(sorter, recurring, recurring);
+    });
+
+    it('sort by due reverse', () => {
+        // Arrange
+        const sorter = new RecurringField().createReverseSorter();
+
+        // Assert
+        expectTaskComparesAfter(sorter, recurring, nonRecurring);
+        expectTaskComparesBefore(sorter, nonRecurring, recurring);
+        expectTaskComparesEqual(sorter, nonRecurring, nonRecurring);
+        expectTaskComparesEqual(sorter, recurring, recurring);
+    });
+});
+
 describe('grouping by recurring', () => {
     it('supports grouping methods correctly', () => {
         expect(new RecurringField()).toSupportGroupingWithProperty('recurring');
diff --git a/tests/TestingTools/TaskBuilder.ts b/tests/TestingTools/TaskBuilder.ts
index fc1d7c0c..0b5a7815 100644
--- a/tests/TestingTools/TaskBuilder.ts
+++ b/tests/TestingTools/TaskBuilder.ts
@@ -194,6 +194,10 @@ export class TaskBuilder {
         return this;
     }
 
+    /**
+     * See {@link RecurrenceBuilder} for easy construction of {@link Recurrence} objects in tests.
+     * @param recurrence
+     */
     public recurrence(recurrence: Recurrence | null): TaskBuilder {
         this._recurrence = recurrence;
         return this;