Repository: obsidian-tasks-group/obsidian-tasks
Pull request #923 (2022-07-23 17:14:18)

feat: Add 'group by happens'
<!--- Provide a general summary of your changes in the Title above -->

## Description

Add `HappensDateField.earliestDate()`

Add new happens  grouping command:

- `group by happens `
- update the Grouping docs page
- update the Quick Reference docs page

This groups by the earliest of `start` date, `scheduled` date, and `due` date, including the week-day, or `No happens date` if none of those are set.

## Motivation and Context

I have found this useful in my fork of this project, so am making it available to others.

This is also part way to implementing #616 - 'Sort By Happens'.


<!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here. -->

## How has this been tested?

- Via new unit tests
- At length in my main vault

## Screenshots (if appropriate):

## Types of changes
<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] Refactor (non-breaking change which only improves the design or structure of existing code, and making no changes to its external behaviour)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)

## 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 accordingly.
- [x] My change has adequate Unit Test coverage.

By creating a Pull Request you agree to our [Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md). For further guidance on contributing please see [contributing guide](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md)



Patch:
diff --git a/docs/queries/grouping.md b/docs/queries/grouping.md
index 1d49e6cc..ec77d086 100644
--- a/docs/queries/grouping.md
+++ b/docs/queries/grouping.md
@@ -44,11 +44,8 @@ File contents:
 1. `backlink` (the text that would be shown in the task's backlink, combining the task's file name and heading, but with no link added)
 1. `heading` (the heading preceding the task, or `(No heading)` if there are no headings in the file)
 
-Task properties:
+Task date properties:
 
-1. `status` (Done or Todo, which is capitalized for visibility in the headings)
-    * Note that the Done group is displayed before the Todo group,
-      which differs from the Sorting ordering of this property.
 1. `start`
    * The start date of the task, including the week-day, or `No start date`.
 1. `scheduled`
@@ -57,6 +54,14 @@ Task properties:
     * The due date of the task, including the week-day, or `No due date`.
 1. `done`
     * The done date of the task, including the week-day, or `No done date`.
+1. `happens`
+    * The earliest of start date, scheduled date, and due date, including the week-day, or `No happens date` if none of those are set.
+
+Task properties - other:
+
+1. `status` (Done or Todo, which is capitalized for visibility in the headings)
+    * Note that the Done group is displayed before the Todo group,
+      which differs from the Sorting ordering of this property.
 1. `priority`
     * The priority of the task, namely one of:
         * `Priority 1: High`
diff --git a/docs/quick-reference/index.md b/docs/quick-reference/index.md
index d1b52876..797ffeef 100644
--- a/docs/quick-reference/index.md
+++ b/docs/quick-reference/index.md
@@ -21,7 +21,7 @@ This table summarizes the filters and other options available inside a `tasks` b
 | `starts (before, after, on) <date>`<br>`has start date`<br>`no  start date`            | `sort by start`                             | `group by start`      | `hide start date`      |
 | `scheduled (before, after, on) <date>`<br>`has scheduled date`<br>`no  scheduled date` | `sort by scheduled`                         | `group by scheduled`  | `hide scheduled date`  |
 | `due (before, after, on) <date>`<br>`has due date`<br>`no  due date`                   | `sort by due`                               | `group by due`        | `hide due date`        |
-| `happens (before, after, on) <date>`<br>`has happens date`<br>`no  happens date`       |                                             |                       |                        |
+| `happens (before, after, on) <date>`<br>`has happens date`<br>`no  happens date`       |                                             | `group by happens`    |                        |
 | `is recurring`<br>`is not recurring`                                                   |                                             | `group by recurring`  |                        |
 |                                                                                        |                                             | `group by recurrence` | `hide recurrence rule` |
 | `priority is (above, below)? (low, none, medium, high)`                                | `sort by priority`                          | `group by priority`   | `hide priority`        |
diff --git a/src/Query.ts b/src/Query.ts
index 21ecba0c..30f1c941 100644
--- a/src/Query.ts
+++ b/src/Query.ts
@@ -30,6 +30,7 @@ export type GroupingProperty =
     | 'due'
     | 'filename'
     | 'folder'
+    | 'happens'
     | 'heading'
     | 'path'
     | 'priority'
@@ -57,7 +58,7 @@ export class Query implements IQuery {
         /^sort by (urgency|status|priority|start|scheduled|due|done|path|description|tag)( reverse)?[\s]*(\d+)?/;
 
     private readonly groupByRegexp =
-        /^group by (backlink|done|due|filename|folder|heading|path|priority|recurrence|recurring|scheduled|start|status|tags)/;
+        /^group by (backlink|done|due|filename|folder|happens|heading|path|priority|recurrence|recurring|scheduled|start|status|tags)/;
 
     private readonly hideOptionsRegexp =
         /^hide (task count|backlink|priority|start date|scheduled date|done date|due date|recurrence rule|edit button)/;
diff --git a/src/Query/Filter/HappensDateField.ts b/src/Query/Filter/HappensDateField.ts
index 264432d9..65773271 100644
--- a/src/Query/Filter/HappensDateField.ts
+++ b/src/Query/Filter/HappensDateField.ts
@@ -1,6 +1,7 @@
 import type { Moment } from 'moment';
 import type { Task } from '../../Task';
 import { DateParser } from '../DateParser';
+import { Sort } from '../../Sort';
 import { Field } from './Field';
 import { FilterOrErrorMessage } from './Filter';
 
@@ -72,6 +73,19 @@ export class HappensDateField extends Field {
         return result;
     }
 
+    /**
+     * Return the earliest of the dates used by 'happens' in the given task, or null if none set.
+     *
+     * Generally speaking, the earliest date is considered to be the highest priority,
+     * as it is the first point at which the user might wish to act on the task.
+     * @param task
+     */
+    public earliestDate(task: Task): Moment | null {
+        const happensDates = new HappensDateField().dates(task);
+        const sortedHappensDates = happensDates.sort(Sort.compareByDate);
+        return sortedHappensDates[0];
+    }
+
     protected filterRegexp(): RegExp {
         return HappensDateField.happensRegexp;
     }
diff --git a/src/Query/Group.ts b/src/Query/Group.ts
index 37501c8d..9418fba1 100644
--- a/src/Query/Group.ts
+++ b/src/Query/Group.ts
@@ -2,6 +2,7 @@ import type { Grouping, GroupingProperty } from '../Query';
 import type { Task } from '../Task';
 import { Priority } from '../Task';
 import { TaskGroups } from './TaskGroups';
+import { HappensDateField } from './Filter/HappensDateField';
 
 /**
  * A naming function, that takes a Task object and returns the corresponding group property name
@@ -42,6 +43,7 @@ export class Group {
         due: Group.groupByDueDate,
         filename: Group.groupByFileName,
         folder: Group.groupByFolder,
+        happens: Group.groupByHappensDate,
         heading: Group.groupByHeading,
         path: Group.groupByPath,
         priority: Group.groupByPriority,
@@ -104,6 +106,11 @@ export class Group {
         return [Group.stringFromDate(task.doneDate, 'done')];
     }
 
+    private static groupByHappensDate(task: Task): string[] {
+        const earliestDateIfAny = new HappensDateField().earliestDate(task);
+        return [Group.stringFromDate(earliestDateIfAny, 'happens')];
+    }
+
     private static stringFromDate(
         date: moment.Moment | null,
         field: string,


Test patch:
diff --git a/tests/Group.test.ts b/tests/Group.test.ts
index a4af62ab..18c6fd90 100644
--- a/tests/Group.test.ts
+++ b/tests/Group.test.ts
@@ -299,6 +299,50 @@ describe('Group names', () => {
             path: 'a.md',
         },
 
+        // -----------------------------------------------------------
+        // group by happens
+        {
+            groupBy: 'happens',
+            taskLine: '- [ ] a',
+            expectedGroupNames: ['No happens date'],
+        },
+        {
+            groupBy: 'happens',
+            taskLine: '- [ ] due is only date 📅 1970-01-01',
+            expectedGroupNames: ['1970-01-01 Thursday'],
+        },
+        {
+            groupBy: 'happens',
+            taskLine: '- [ ] scheduled is only date ⏳ 1970-01-02',
+            expectedGroupNames: ['1970-01-02 Friday'],
+        },
+        {
+            groupBy: 'happens',
+            taskLine: '- [ ] start is only date 🛫 1970-01-03',
+            expectedGroupNames: ['1970-01-03 Saturday'],
+        },
+        {
+            // Check that earliest date is prioritised: due
+            groupBy: 'happens',
+            taskLine:
+                '- [ ] due is earliest date 🛫 1970-01-03 ⏳ 1970-01-02 📅 1970-01-01',
+            expectedGroupNames: ['1970-01-01 Thursday'],
+        },
+        {
+            // Check that earliest date is prioritised: scheduled
+            groupBy: 'happens',
+            taskLine:
+                '- [ ] scheduled is earliest date 🛫 1970-01-03 ⏳ 1970-01-01 📅 1970-01-02',
+            expectedGroupNames: ['1970-01-01 Thursday'],
+        },
+        {
+            // Check that earliest date is prioritised: start
+            groupBy: 'happens',
+            taskLine:
+                '- [ ] start is earliest date 🛫 1970-01-01 ⏳ 1970-01-02 📅 1970-01-03',
+            expectedGroupNames: ['1970-01-01 Thursday'],
+        },
+
         // -----------------------------------------------------------
         // group by heading
         {
diff --git a/tests/Query.test.ts b/tests/Query.test.ts
index e332991f..d98e0c15 100644
--- a/tests/Query.test.ts
+++ b/tests/Query.test.ts
@@ -164,6 +164,7 @@ describe('Query parsing', () => {
             'group by due',
             'group by filename',
             'group by folder',
+            'group by happens',
             'group by heading',
             'group by path',
             'group by priority',
diff --git a/tests/Query/Filter/HappensDateField.test.ts b/tests/Query/Filter/HappensDateField.test.ts
index 75aac0a8..35a2303c 100644
--- a/tests/Query/Filter/HappensDateField.test.ts
+++ b/tests/Query/Filter/HappensDateField.test.ts
@@ -49,3 +49,56 @@ describe('happens date', () => {
         testFilter(filter, new TaskBuilder().doneDate('2022-04-15'), true);
     });
 });
+
+describe('accessing earliest happens date', () => {
+    it('should return null if no dates set', () => {
+        expect(
+            new HappensDateField().earliestDate(new TaskBuilder().build()),
+        ).toBeNull();
+    });
+
+    function checkEarliestHappensDate(
+        taskBuilder: TaskBuilder,
+        expectedEarliestHappensDate: string,
+    ) {
+        const earliest = new HappensDateField().earliestDate(
+            taskBuilder.build(),
+        );
+        expect({
+            earliest: earliest?.format('YYYY-MM-DD'),
+        }).toMatchObject({
+            earliest: expectedEarliestHappensDate,
+        });
+    }
+
+    it('should return due if only date set', () => {
+        checkEarliestHappensDate(
+            new TaskBuilder().dueDate('1989-12-17'),
+            '1989-12-17',
+        );
+    });
+
+    it('should return start if only date set', () => {
+        checkEarliestHappensDate(
+            new TaskBuilder().startDate('1989-12-17'),
+            '1989-12-17',
+        );
+    });
+
+    it('should return scheduled if only date set', () => {
+        checkEarliestHappensDate(
+            new TaskBuilder().scheduledDate('1989-12-17'),
+            '1989-12-17',
+        );
+    });
+
+    it('should return earliest if all dates set', () => {
+        checkEarliestHappensDate(
+            new TaskBuilder()
+                .dueDate('1989-12-17')
+                .startDate('1999-12-17')
+                .scheduledDate('2009-12-17'),
+            '1989-12-17',
+        );
+    });
+});