Repository: obsidian-tasks-group/obsidian-tasks
Pull request #1954 (2023-05-15 12:10:08)

feat: reverse sorting of groups
<!--- Provide a general summary of your changes in the Title above -->

# Description

Enable reverse sorting of groups (not the tasks in the groups but the groups themselves).

Done by pairing with @claremacrae 

## Motivation and Context

- https://github.com/obsidian-tasks-group/obsidian-tasks/discussions/780


## How has this been tested?

Added unit 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)

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)

## 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.
-->

- [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/src/Query/Filter/Field.ts b/src/Query/Filter/Field.ts
index b3878a71..b3425831 100644
--- a/src/Query/Filter/Field.ts
+++ b/src/Query/Filter/Field.ts
@@ -264,6 +264,87 @@ export abstract class Field {
         return false;
     }
 
+    /**
+     * Parse a 'group by' line and return a {@link Grouper} object.
+     *
+     * Returns null line does not match this field or is invalid,
+     * or this field does not support grouping.
+     */
+    public parseGroupLine(line: string): Grouper | null {
+        if (!this.supportsGrouping()) {
+            return null;
+        }
+
+        if (!this.canCreateGrouperForLine(line)) {
+            return null;
+        }
+
+        return this.createGrouperFromLine(line);
+    }
+
+    /**
+     * Returns true if the class can parse the given 'group by' instruction line.
+     *
+     * Current implementation simply checks whether the class does support grouping,
+     * and whether the line matches this.grouperRegExp().
+     * @param line - A line from a ```tasks``` block.
+     *
+     * @see {@link createGrouperFromLine}
+     */
+    public canCreateGrouperForLine(line: string): boolean {
+        if (!this.supportsGrouping()) {
+            return false;
+        }
+
+        return Field.lineMatchesFilter(this.grouperRegExp(), line);
+    }
+
+    /**
+     * Parse the line, and return either a {@link Grouper} object or null.
+     *
+     * This default implementation works for all fields that support
+     * the default grouping pattern of `group by <fieldName> (reverse)?`.
+     *
+     * Fields that offer more complicated 'group by' options can override
+     * this method.
+     *
+     * @param line - A 'group by' line from a ```tasks``` block.
+     *
+     * @see {@link canCreateGrouperForLine}
+     */
+    public createGrouperFromLine(line: string): Grouper | null {
+        if (!this.supportsGrouping()) {
+            return null;
+        }
+
+        const match = Field.getMatch(this.grouperRegExp(), line);
+        if (match === null) {
+            return null;
+        }
+
+        const reverse = !!match[1];
+        return this.createGrouper(reverse);
+    }
+
+    /**
+     * Return a regular expression that will match a correctly-formed
+     * instruction line for grouping Tasks by this field.
+     *
+     * Throws if this field does not support grouping.
+     *
+     * `match[1]` will be either `reverse` or undefined.
+     *
+     * Fields that offer more complicated 'group by' options can override
+     * this method.
+     */
+    protected grouperRegExp(): RegExp {
+        if (!this.supportsGrouping()) {
+            throw Error(`grouperRegExp() unimplemented for ${this.fieldNameSingular()}`);
+        }
+
+        return new RegExp(`^group by ${this.fieldNameSingularEscaped()}( reverse)?$`);
+    }
+
     /**
      * Return a function to get a list of a task's group names, for use in grouping by this field's value.
      *
@@ -276,11 +357,29 @@ export abstract class Field {
 
     /**
      * Create a {@link Grouper} object for grouping tasks by this field's value.
+     * @param reverse - false for normal group order, true for reverse group order.
+     */
+    public createGrouper(reverse: boolean): Grouper {
+        return new Grouper(this.fieldNameSingular(), this.grouper(), reverse);
+    }
+
+    /**
+     * Create a {@link Grouper} object for grouping tasks by this field's value,
+     * in the standard/normal group order for this field.
+     *
+     * @see {@link createReverseGrouper}
+     */
+    public createNormalGrouper(): Grouper {
+        return this.createGrouper(false);
+    }
+
+    /**
+     * Create a {@link Grouper} object for grouping tasks by this field's value,
+     * in the reverse of the standard/normal group order for this field.
      *
-     * For now, parsing of `group by` lines is currently done in {@link FilterParser.parseGrouper()}.
-     * Later, this will probably be moved to the {@link Field} classes.
+     * @see {@link createNormalGrouper}
      */
-    public createGrouper(): Grouper {
-        return new Grouper(this.fieldNameSingular(), this.grouper());
+    public createReverseGrouper(): Grouper {
+        return this.createGrouper(true);
     }
 }
diff --git a/src/Query/Filter/MultiTextField.ts b/src/Query/Filter/MultiTextField.ts
index a6ba8d2e..ef6a0d70 100644
--- a/src/Query/Filter/MultiTextField.ts
+++ b/src/Query/Filter/MultiTextField.ts
@@ -63,7 +63,15 @@ export abstract class MultiTextField extends TextField {
     /**
      * This overloads {@link Field.createGrouper} to put a plural field name in the {@link Grouper.property}.
      */
-    public createGrouper(): Grouper {
-        return new Grouper(this.fieldNamePlural(), this.grouper());
+    public createGrouper(reverse: boolean): Grouper {
+        return new Grouper(this.fieldNamePlural(), this.grouper(), reverse);
+    }
+
+    protected grouperRegExp(): RegExp {
+        if (!this.supportsGrouping()) {
+            throw Error(`grouperRegExp() unimplemented for ${this.fieldNameSingular()}`);
+        }
+
+        return new RegExp(`^group by ${this.fieldNamePlural()}( reverse)?$`);
     }
 }
diff --git a/src/Query/FilterParser.ts b/src/Query/FilterParser.ts
index dc112482..3c5d6944 100644
--- a/src/Query/FilterParser.ts
+++ b/src/Query/FilterParser.ts
@@ -23,7 +23,6 @@ import { RecurrenceField } from './Filter/RecurrenceField';
 import type { FilterOrErrorMessage } from './Filter/Filter';
 import type { Sorter } from './Sorter';
 import type { Grouper } from './Grouper';
-import { MultiTextField } from './Filter/MultiTextField';
 import { FolderField } from './Filter/FolderField';
 import { RootField } from './Filter/RootField';
 import { BacklinkField } from './Filter/BacklinkField';
@@ -102,19 +101,9 @@ export function parseGrouper(line: string): Grouper | null {
     // See if any of the fields can parse the line.
     for (const creator of fieldCreators) {
         const field = creator();
-        const fieldName = field.fieldNameSingular();
-        if (field.supportsGrouping()) {
-            if (line === `group by ${fieldName}`) {
-                return field.createGrouper();
-            }
-
-            // MultiTextField is written as a plural ('group by tags')
-            // See also MultiTextField.createGrouper()
-            if (field instanceof MultiTextField) {
-                if (line === `group by ${field.fieldNamePlural()}`) {
-                    return field.createGrouper();
-                }
-            }
+        const grouper = field.parseGroupLine(line);
+        if (grouper) {
+            return grouper;
         }
     }
     return null;
diff --git a/src/Query/Grouper.ts b/src/Query/Grouper.ts
index 67f2639c..8df4050e 100644
--- a/src/Query/Grouper.ts
+++ b/src/Query/Grouper.ts
@@ -34,8 +34,11 @@ export class Grouper {
      */
     public readonly grouper: GrouperFunction;
 
-    constructor(property: string, grouper: GrouperFunction) {
+    public readonly reverse: boolean;
+
+    constructor(property: string, grouper: GrouperFunction, reverse: boolean) {
         this.property = property;
         this.grouper = grouper;
+        this.reverse = reverse;
     }
 }
diff --git a/src/Query/TaskGroups.ts b/src/Query/TaskGroups.ts
index 66f67686..0e752f5a 100644
--- a/src/Query/TaskGroups.ts
+++ b/src/Query/TaskGroups.ts
@@ -13,6 +13,7 @@ import { TaskGroup } from './TaskGroup';
  * @see {@link Query.grouping}
  */
 export class TaskGroups {
+    private _groupers: Grouper[];
     private _groups: TaskGroup[] = new Array<TaskGroup>();
     private _totalTaskCount = 0;
 
@@ -26,6 +27,7 @@ export class TaskGroups {
         // Grouping doesn't change the number of tasks, and all the tasks
         // will be shown in at least one group.
         this._totalTaskCount = tasks.length;
+        this._groupers = groups;
 
         const taskGroupingTree = new TaskGroupingTree(groups, tasks);
         this.addTasks(taskGroupingTree);
@@ -103,9 +105,10 @@ export class TaskGroups {
                 // In future, we will add control over the sorting of group headings,
                 // which will likely involve adjusting this code to sort by applying a Comparator
                 // to the first Task in each group.
+                const grouper = this._groupers[i];
                 const result = groupNames1[i].localeCompare(groupNames2[i], undefined, { numeric: true });
                 if (result !== 0) {
-                    return result;
+                    return grouper.reverse ? -result : result;
                 }
             }
             // identical if we reach here


Test patch:
diff --git a/tests/CustomMatchers/CustomMatchersForGrouping.ts b/tests/CustomMatchers/CustomMatchersForGrouping.ts
index 5f94cfd0..29810e62 100644
--- a/tests/CustomMatchers/CustomMatchersForGrouping.ts
+++ b/tests/CustomMatchers/CustomMatchersForGrouping.ts
@@ -24,7 +24,7 @@ export function toSupportGroupingWithProperty(field: Field, property: string) {
         };
     }
 
-    const fieldGrouper = field.createGrouper();
+    const fieldGrouper = field.createNormalGrouper();
     if (fieldGrouper.property !== property) {
         return {
             message: () =>
diff --git a/tests/DocumentationSamples/DocsSamplesForStatuses.test.ts b/tests/DocumentationSamples/DocsSamplesForStatuses.test.ts
index f1256807..015eadf5 100644
--- a/tests/DocumentationSamples/DocsSamplesForStatuses.test.ts
+++ b/tests/DocumentationSamples/DocsSamplesForStatuses.test.ts
@@ -208,9 +208,9 @@ function verifyTransitionsAsMarkdownTable(statuses: Status[]) {
         table.addRow(cells);
     }
 
-    showGroupNamesForAllTasks('status', new StatusField().createGrouper().grouper);
-    showGroupNamesForAllTasks('status.type', new StatusTypeField().createGrouper().grouper);
-    showGroupNamesForAllTasks('status.name', new StatusNameField().createGrouper().grouper);
+    showGroupNamesForAllTasks('status', new StatusField().createNormalGrouper().grouper);
+    showGroupNamesForAllTasks('status.type', new StatusTypeField().createNormalGrouper().grouper);
+    showGroupNamesForAllTasks('status.name', new StatusNameField().createNormalGrouper().grouper);
 
     table.verifyForDocs();
 }
diff --git a/tests/Query.test.ts b/tests/Query.test.ts
index 43a2e03a..f65bb7e8 100644
--- a/tests/Query.test.ts
+++ b/tests/Query.test.ts
@@ -201,25 +201,45 @@ describe('Query parsing', () => {
         // In alphabetical order, please
         const filters = [
             'group by created',
+            'group by created reverse',
             'group by backlink',
+            'group by backlink reverse',
             'group by done',
+            'group by done reverse',
             'group by due',
+            'group by due reverse',
             'group by filename',
+            'group by filename reverse',
             'group by folder',
+            'group by folder reverse',
             'group by happens',
+            'group by happens reverse',
             'group by heading',
+            'group by heading reverse',
             'group by path',
+            'group by path reverse',
             'group by priority',
+            'group by priority reverse',
             'group by recurrence',
+            'group by recurrence reverse',
             'group by recurring',
+            'group by recurring reverse',
             'group by root',
+            'group by root reverse',
             'group by scheduled',
+            'group by scheduled reverse',
             'group by start',
+            'group by start reverse',
             'group by status',
+            'group by status reverse',
             'group by status.name',
+            'group by status.name reverse',
             'group by status.type',
+            'group by status.type reverse',
             'group by tags',
+            'group by tags reverse',
             'group by urgency',
+            'group by urgency reverse',
         ];
         test.concurrent.each<string>(filters)('recognises %j', (filter) => {
             // Arrange
diff --git a/tests/Query/Filter/BacklinkField.test.ts b/tests/Query/Filter/BacklinkField.test.ts
index b4507617..375a64a9 100644
--- a/tests/Query/Filter/BacklinkField.test.ts
+++ b/tests/Query/Filter/BacklinkField.test.ts
@@ -58,7 +58,7 @@ describe('grouping by backlink', () => {
         'path "%s" and heading "%s" should have groups: %s',
         (path: string, heading: string | null, groups: string[]) => {
             // Arrange
-            const grouper = new BacklinkField().createGrouper().grouper;
+            const grouper = new BacklinkField().createNormalGrouper().grouper;
             const t = '- [ ] xyz';
 
             // Assert
diff --git a/tests/Query/Filter/CreatedDateField.test.ts b/tests/Query/Filter/CreatedDateField.test.ts
index 4bdd2855..b3a535d5 100644
--- a/tests/Query/Filter/CreatedDateField.test.ts
+++ b/tests/Query/Filter/CreatedDateField.test.ts
@@ -97,7 +97,7 @@ describe('grouping by created date', () => {
 
     it('group by created date', () => {
         // Arrange
-        const grouper = new CreatedDateField().createGrouper();
+        const grouper = new CreatedDateField().createNormalGrouper();
         const taskWithDate = new TaskBuilder().createdDate('1970-01-01').build();
         const taskWithoutDate = new TaskBuilder().build();
 
diff --git a/tests/Query/Filter/DoneDateField.test.ts b/tests/Query/Filter/DoneDateField.test.ts
index 42091a00..18441cb6 100644
--- a/tests/Query/Filter/DoneDateField.test.ts
+++ b/tests/Query/Filter/DoneDateField.test.ts
@@ -94,7 +94,7 @@ describe('grouping by done date', () => {
 
     it('group by done date', () => {
         // Arrange
-        const grouper = new DoneDateField().createGrouper();
+        const grouper = new DoneDateField().createNormalGrouper();
         const taskWithDate = new TaskBuilder().doneDate('1970-01-01').build();
         const taskWithoutDate = new TaskBuilder().build();
 
diff --git a/tests/Query/Filter/DueDateField.test.ts b/tests/Query/Filter/DueDateField.test.ts
index d842d7c4..342bc079 100644
--- a/tests/Query/Filter/DueDateField.test.ts
+++ b/tests/Query/Filter/DueDateField.test.ts
@@ -523,7 +523,7 @@ describe('grouping by due date', () => {
 
     it('group by due date', () => {
         // Arrange
-        const grouper = new DueDateField().createGrouper();
+        const grouper = new DueDateField().createNormalGrouper();
         const taskWithDate = new TaskBuilder().dueDate('1970-01-01').build();
         const taskWithoutDate = new TaskBuilder().build();
 
diff --git a/tests/Query/Filter/FilenameField.test.ts b/tests/Query/Filter/FilenameField.test.ts
index 3ffb0388..19dabb94 100644
--- a/tests/Query/Filter/FilenameField.test.ts
+++ b/tests/Query/Filter/FilenameField.test.ts
@@ -114,7 +114,7 @@ describe('grouping by filename', () => {
         ['- [ ] a', 'a/b/_c_.md', ['[[_c_]]']],
     ])('task "%s" with path "%s" should have groups: %s', (taskLine: string, path: string, groups: string[]) => {
         // Arrange
-        const grouper = new FilenameField().createGrouper().grouper;
+        const grouper = new FilenameField().createNormalGrouper().grouper;
 
         // Assert
         expect(grouper(fromLine({ line: taskLine, path: path }))).toEqual(groups);
diff --git a/tests/Query/Filter/FolderField.test.ts b/tests/Query/Filter/FolderField.test.ts
index b911d8e1..fdf5c29a 100644
--- a/tests/Query/Filter/FolderField.test.ts
+++ b/tests/Query/Filter/FolderField.test.ts
@@ -43,7 +43,7 @@ describe('grouping by folder', () => {
         ['- [ ] a', 'a.md', ['/']],
     ])('task "%s" with path "%s" should have groups: %s', (taskLine: string, path: string, groups: string[]) => {
         // Arrange
-        const grouper = new FolderField().createGrouper().grouper;
+        const grouper = new FolderField().createNormalGrouper().grouper;
 
         // Assert
         expect(grouper(fromLine({ line: taskLine, path: path }))).toEqual(groups);
diff --git a/tests/Query/Filter/HappensDateField.test.ts b/tests/Query/Filter/HappensDateField.test.ts
index 3e6535ae..e1617ffb 100644
--- a/tests/Query/Filter/HappensDateField.test.ts
+++ b/tests/Query/Filter/HappensDateField.test.ts
@@ -150,7 +150,7 @@ describe('grouping by happens date', () => {
         ['- [ ] start is earliest date 🛫 1970-01-01 ⏳ 1970-01-02 📅 1970-01-03', ['1970-01-01 Thursday']],
     ])('group by happens date: task "%s" should have groups %s', (taskLine: string, expectedResult: string[]) => {
         // Arrange
-        const grouper = new HappensDateField().createGrouper();
+        const grouper = new HappensDateField().createNormalGrouper();
 
         // Assert
         expect(grouper.grouper(fromLine({ line: taskLine }))).toEqual(expectedResult);
diff --git a/tests/Query/Filter/HeadingField.test.ts b/tests/Query/Filter/HeadingField.test.ts
index e260da75..8c190704 100644
--- a/tests/Query/Filter/HeadingField.test.ts
+++ b/tests/Query/Filter/HeadingField.test.ts
@@ -104,7 +104,7 @@ describe('grouping by heading', () => {
         'task "%s" with header "%s" should have groups: %s',
         (taskLine: string, header: string | null, groups: string[]) => {
             // Arrange
-            const grouper = new HeadingField().createGrouper().grouper;
+            const grouper = new HeadingField().createNormalGrouper().grouper;
 
             // Assert
             expect(grouper(fromLine({ line: taskLine, precedingHeader: header }))).toEqual(groups);
diff --git a/tests/Query/Filter/PathField.test.ts b/tests/Query/Filter/PathField.test.ts
index c418500e..6f4f9e5a 100644
--- a/tests/Query/Filter/PathField.test.ts
+++ b/tests/Query/Filter/PathField.test.ts
@@ -158,7 +158,7 @@ describe('grouping by path', () => {
         ['- [ ] a', 'a\\b\\c.md', ['a\\\\b\\\\c']],
     ])('task "%s" with path "%s" should have groups: %s', (taskLine: string, path: string, groups: string[]) => {
         // Arrange
-        const grouper = new PathField().createGrouper().grouper;
+        const grouper = new PathField().createNormalGrouper().grouper;
 
         // Assert
         expect(grouper(fromLine({ line: taskLine, path: path }))).toEqual(groups);
diff --git a/tests/Query/Filter/PriorityField.test.ts b/tests/Query/Filter/PriorityField.test.ts
index c10ca095..d909fb39 100644
--- a/tests/Query/Filter/PriorityField.test.ts
+++ b/tests/Query/Filter/PriorityField.test.ts
@@ -169,7 +169,7 @@ describe('grouping by priority', () => {
         ['- [ ] a 🔽', ['Priority 4: Low']],
     ])('task "%s" should have groups: %s', (taskLine: string, groups: string[]) => {
         // Arrange
-        const grouper = new PriorityField().createGrouper().grouper;
+        const grouper = new PriorityField().createNormalGrouper().grouper;
 
         // Assert
         expect(grouper(fromLine({ line: taskLine }))).toEqual(groups);
diff --git a/tests/Query/Filter/RecurrenceField.test.ts b/tests/Query/Filter/RecurrenceField.test.ts
index 7bb4a8cb..476a0351 100644
--- a/tests/Query/Filter/RecurrenceField.test.ts
+++ b/tests/Query/Filter/RecurrenceField.test.ts
@@ -88,7 +88,7 @@ describe('grouping by recurrence', () => {
         ['- [ ] a 🔁 every 6 months on the 2nd Wednesday', ['every 6 months on the 2nd Wednesday']],
     ])('task "%s" should have groups: %s', (taskLine: string, groups: string[]) => {
         // Arrange
-        const grouper = new RecurrenceField().createGrouper().grouper;
+        const grouper = new RecurrenceField().createNormalGrouper().grouper;
 
         // Assert
         expect(grouper(fromLine({ line: taskLine }))).toEqual(groups);
diff --git a/tests/Query/Filter/RecurringField.test.ts b/tests/Query/Filter/RecurringField.test.ts
index 56dc1cd0..b094784d 100644
--- a/tests/Query/Filter/RecurringField.test.ts
+++ b/tests/Query/Filter/RecurringField.test.ts
@@ -96,7 +96,7 @@ describe('grouping by recurring', () => {
         ['- [ ] a 🔁 every Sunday', ['Recurring']],
     ])('task "%s" should have groups: %s', (taskLine: string, groups: string[]) => {
         // Arrange
-        const grouper = new RecurringField().createGrouper().grouper;
+        const grouper = new RecurringField().createNormalGrouper().grouper;
 
         // Assert
         expect(grouper(fromLine({ line: taskLine }))).toEqual(groups);
diff --git a/tests/Query/Filter/RootField.test.ts b/tests/Query/Filter/RootField.test.ts
index b97dad7f..f5e04f2f 100644
--- a/tests/Query/Filter/RootField.test.ts
+++ b/tests/Query/Filter/RootField.test.ts
@@ -46,7 +46,7 @@ describe('grouping by root', () => {
         ['- [ ] a', 'a.md', ['/']],
     ])('task "%s" with path "%s" should have groups: %s', (taskLine: string, path: string, groups: string[]) => {
         // Arrange
-        const grouper = new RootField().createGrouper().grouper;
+        const grouper = new RootField().createNormalGrouper().grouper;
 
         // Assert
         expect(grouper(fromLine({ line: taskLine, path: path }))).toEqual(groups);
diff --git a/tests/Query/Filter/ScheduledDateField.test.ts b/tests/Query/Filter/ScheduledDateField.test.ts
index 4f37ee71..e41735d4 100644
--- a/tests/Query/Filter/ScheduledDateField.test.ts
+++ b/tests/Query/Filter/ScheduledDateField.test.ts
@@ -48,7 +48,7 @@ describe('grouping by scheduled date', () => {
 
     it('group by scheduled date', () => {
         // Arrange
-        const grouper = new ScheduledDateField().createGrouper();
+        const grouper = new ScheduledDateField().createNormalGrouper();
         const taskWithDate = new TaskBuilder().scheduledDate('1970-01-01').build();
         const taskWithoutDate = new TaskBuilder().build();
 
diff --git a/tests/Query/Filter/StartDateField.test.ts b/tests/Query/Filter/StartDateField.test.ts
index 1d2e888c..271bcbe1 100644
--- a/tests/Query/Filter/StartDateField.test.ts
+++ b/tests/Query/Filter/StartDateField.test.ts
@@ -64,7 +64,7 @@ describe('grouping by start date', () => {
 
     it('group by start date', () => {
         // Arrange
-        const grouper = new StartDateField().createGrouper();
+        const grouper = new StartDateField().createNormalGrouper();
         const taskWithDate = new TaskBuilder().startDate('1970-01-01').build();
         const taskWithoutDate = new TaskBuilder().build();
 
diff --git a/tests/Query/Filter/StatusField.test.ts b/tests/Query/Filter/StatusField.test.ts
index 7949dd75..14e827c2 100644
--- a/tests/Query/Filter/StatusField.test.ts
+++ b/tests/Query/Filter/StatusField.test.ts
@@ -96,7 +96,7 @@ describe('grouping by status', () => {
         ['- [!] a', ['Done']],
     ])('task "%s" should have groups: %s', (taskLine: string, groups: string[]) => {
         // Arrange
-        const grouper = new StatusField().createGrouper().grouper;
+        const grouper = new StatusField().createNormalGrouper().grouper;
 
         // Assert
         expect(grouper(fromLine({ line: taskLine }))).toEqual(groups);
diff --git a/tests/Query/Filter/StatusNameField.test.ts b/tests/Query/Filter/StatusNameField.test.ts
index f6f06965..ba16d618 100644
--- a/tests/Query/Filter/StatusNameField.test.ts
+++ b/tests/Query/Filter/StatusNameField.test.ts
@@ -91,7 +91,7 @@ describe('grouping by status.name', () => {
 
     it('group by status.name', () => {
         // Arrange
-        const grouper = new StatusNameField().createGrouper();
+        const grouper = new StatusNameField().createNormalGrouper();
 
         // // Assert
         expect(grouper.grouper(todoTask)).toEqual(['Todo']);
diff --git a/tests/Query/Filter/StatusTypeField.test.ts b/tests/Query/Filter/StatusTypeField.test.ts
index ebd743a8..6feb781b 100644
--- a/tests/Query/Filter/StatusTypeField.test.ts
+++ b/tests/Query/Filter/StatusTypeField.test.ts
@@ -149,7 +149,7 @@ describe('grouping by status.type', () => {
 
     it('group by status.type', () => {
         // Arrange
-        const grouper = new StatusTypeField().createGrouper();
+        const grouper = new StatusTypeField().createNormalGrouper();
 
         // // Assert
         expect(grouper.grouper(inprTask)).toEqual(['1 IN_PROGRESS']);
diff --git a/tests/Query/Filter/TagsField.test.ts b/tests/Query/Filter/TagsField.test.ts
index 9a5b8147..ef27b255 100644
--- a/tests/Query/Filter/TagsField.test.ts
+++ b/tests/Query/Filter/TagsField.test.ts
@@ -7,6 +7,8 @@ import { fromLine } from '../../TestHelpers';
 import { Sort } from '../../../src/Query/Sort';
 import { TaskBuilder } from '../../TestingTools/TaskBuilder';
 import { expectTaskComparesAfter, expectTaskComparesBefore } from '../../CustomMatchers/CustomMatchersForSorting';
+import type { Grouper } from '../../../src/Query/Grouper';
+import { TaskGroups } from '../../../src/Query/TaskGroups';
 
 describe('tag presence & absence', () => {
     it.each(['has tag', 'has tags'])('should have "%s" filtering', (filterLine: string) => {
@@ -639,9 +641,39 @@ describe('grouping by tag', () => {
         ['- [x] a', ['(No tags)']],
     ])('task "%s" should have groups: %s', (taskLine: string, groups: string[]) => {
         // Arrange
-        const grouper = new TagsField().createGrouper().grouper;
+        const grouper = new TagsField().createNormalGrouper().grouper;
 
         // Assert
         expect(grouper(fromLine({ line: taskLine }))).toEqual(groups);
     });
+
+    it('sorts headings in reverse', () => {
+        // Arrange
+        const a = fromLine({ line: '- [ ] a #tag1' });
+        const b = fromLine({ line: '- [ ] b #tag2' });
+        const inputs = [a, b];
+
+        // Act
+        const grouping: Grouper[] = [new TagsField().createGrouperFromLine('group by tags reverse')!];
+        const groups = new TaskGroups(grouping, inputs);
+
+        // Assert
+        expect(groups.toString()).toMatchInlineSnapshot(`
+            "
+            Group names: [#tag2]
+            #### #tag2
+            - [ ] b #tag2
+
+            ---
+
+            Group names: [#tag1]
+            #### #tag1
+            - [ ] a #tag1
+
+            ---
+
+            2 tasks
+            "
+        `);
+    });
 });
diff --git a/tests/Query/Filter/UrgencyField.test.ts b/tests/Query/Filter/UrgencyField.test.ts
index 946abc3b..d079bc50 100644
--- a/tests/Query/Filter/UrgencyField.test.ts
+++ b/tests/Query/Filter/UrgencyField.test.ts
@@ -91,7 +91,7 @@ describe('grouping by urgency', () => {
         ['- [ ] a 🔽', ['0.00']],
     ])('task "%s" should have groups: %s', (taskLine: string, groups: string[]) => {
         // Arrange
-        const grouper = new UrgencyField().createGrouper().grouper;
+        const grouper = new UrgencyField().createNormalGrouper().grouper;
 
         // Assert
         expect(grouper(fromLine({ line: taskLine }))).toEqual(groups);
diff --git a/tests/TaskGroups.test.ts b/tests/TaskGroups.test.ts
index 9a6c104b..f3785f2e 100644
--- a/tests/TaskGroups.test.ts
+++ b/tests/TaskGroups.test.ts
@@ -11,6 +11,7 @@ import { FolderField } from '../src/Query/Filter/FolderField';
 import { TaskGroups } from '../src/Query/TaskGroups';
 import { StatusTypeField } from '../src/Query/Filter/StatusTypeField';
 import { HappensDateField } from '../src/Query/Filter/HappensDateField';
+import { DueDateField } from '../src/Query/Filter/DueDateField';
 import { fromLine } from './TestHelpers';
 
 window.moment = moment;
@@ -24,7 +25,7 @@ describe('Grouping tasks', () => {
         const inputs = [a, b, c];
 
         // Act
-        const grouping = [new PathField().createGrouper()];
+        const grouping = [new PathField().createNormalGrouper()];
         const groups = new TaskGroups(grouping, inputs);
 
         // Assert
@@ -78,7 +79,7 @@ describe('Grouping tasks', () => {
     it('groups empty task list correctly', () => {
         // Arrange
         const inputs: Task[] = [];
-        const grouping = [new PathField().createGrouper()];
+        const grouping = [new PathField().createNormalGrouper()];
 
         // Act
         const groups = new TaskGroups(grouping, inputs);
@@ -104,7 +105,7 @@ describe('Grouping tasks', () => {
         });
         const inputs = [a, b, c];
 
-        const grouping = [new PathField().createGrouper()];
+        const grouping = [new PathField().createNormalGrouper()];
         const groups = new TaskGroups(grouping, inputs);
         expect(groups.toString()).toMatchInlineSnapshot(`
             "
@@ -142,7 +143,7 @@ describe('Grouping tasks', () => {
         });
         const inputs = [a, b];
 
-        const grouping = [new FilenameField().createGrouper()];
+        const grouping = [new FilenameField().createNormalGrouper()];
         const groups = new TaskGroups(grouping, inputs);
         expect(groups.toString()).toMatchInlineSnapshot(`
             "
@@ -163,6 +164,37 @@ describe('Grouping tasks', () => {
         `);
     });
 
+    it('sorts due date group headings in reverse', () => {
+        // Arrange
+        const a = fromLine({ line: '- [ ] a 📅 2023-04-05', path: '2.md' });
+        const b = fromLine({ line: '- [ ] b 📅 2023-07-08', path: '3.md' });
+        const inputs = [a, b];
+
+        // Act
+        const grouping: Grouper[] = [new DueDateField().createGrouperFromLine('group by due reverse')!];
+        const groups = new TaskGroups(grouping, inputs);
+
+        // Assert
+        // No grouping specified, so no headings generated
+        expect(groups.toString()).toMatchInlineSnapshot(`
+            "
+            Group names: [2023-07-08 Saturday]
+            #### 2023-07-08 Saturday
+            - [ ] b 📅 2023-07-08
+
+            ---
+
+            Group names: [2023-04-05 Wednesday]
+            #### 2023-04-05 Wednesday
+            - [ ] a 📅 2023-04-05
+
+            ---
+
+            2 tasks
+            "
+        `);
+    });
+
     it('handles tasks matching multiple groups correctly', () => {
         const a = fromLine({
             line: '- [ ] Task 1 #group1',
@@ -175,7 +207,7 @@ describe('Grouping tasks', () => {
         });
         const inputs = [a, b, c];
 
-        const grouping = [new TagsField().createGrouper()];
+        const grouping = [new TagsField().createNormalGrouper()];
         const groups = new TaskGroups(grouping, inputs);
         expect(groups.toString()).toMatchInlineSnapshot(`
             "
@@ -198,7 +230,7 @@ describe('Grouping tasks', () => {
         `);
     });
 
-    it('should create nested headings if multiple groups used', () => {
+    it('should create nested headings if multiple groups used even if one is reversed', () => {
         // Arrange
         const t1 = fromLine({
             line: '- [ ] Task 1 - but path is 2nd, alphabetically',
@@ -214,7 +246,10 @@ describe('Grouping tasks', () => {
         });
         const tasks = [t1, t2, t3];
 
-        const grouping: Grouper[] = [new FolderField().createGrouper(), new FilenameField().createGrouper()];
+        const grouping: Grouper[] = [
+            new FolderField().createReverseGrouper(),
+            new FilenameField().createNormalGrouper(),
+        ];
 
         // Act
         const groups = new TaskGroups(grouping, tasks);
@@ -222,13 +257,6 @@ describe('Grouping tasks', () => {
         // Assert
         expect(groups.toString()).toMatchInlineSnapshot(`
             "
-            Group names: [folder\\_a/folder\\_b/,[[file_c]]]
-            #### folder\\_a/folder\\_b/
-            ##### [[file_c]]
-            - [ ] Task 3 - but path is 1st, alphabetically
-
-            ---
-
             Group names: [folder\\_b/folder\\_c/,[[file_c]]]
             #### folder\\_b/folder\\_c/
             ##### [[file_c]]
@@ -242,6 +270,13 @@ describe('Grouping tasks', () => {
 
             ---
 
+            Group names: [folder\\_a/folder\\_b/,[[file_c]]]
+            #### folder\\_a/folder\\_b/
+            ##### [[file_c]]
+            - [ ] Task 3 - but path is 1st, alphabetically
+
+            ---
+
             3 tasks
             "
         `);
@@ -260,8 +295,8 @@ describe('Grouping tasks', () => {
         const inputs = [a, b, c];
 
         const grouping = [
-            new StatusTypeField().createGrouper(), // Two group levels
-            new HappensDateField().createGrouper(),
+            new StatusTypeField().createNormalGrouper(), // Two group levels
+            new HappensDateField().createNormalGrouper(),
         ];
         const groups = new TaskGroups(grouping, inputs);
         // This result is incorrect. The '2 TODO' heading is shown before