Repository: obsidian-tasks-group/obsidian-tasks
Pull request #2312 (2023-10-01 20:03:10)

feat: Add custom grouping by 'date category' and 'time from now'
<!--- Provide a general summary of your changes in the Title above -->

# Description

<!--- Describe your changes in detail -->
Add the following new custom grouping options (they work for all dates, of course, not just due dates)

### 1 Easy grouping of tasks by date category

```
group by function task.due.category.groupText
```

The categories are:

- `Overdue`
- `Today`
- `Future`
- `Undated`
- And they are numbered 1, 2, 3 and 4, in the order listed above.


### 2 Easy grouping of tasks by time from now

```
group by function task.due.fromNow.groupText
```

Example output:

- `2 months ago`
- `8 days ago`
- `in 11 hours`
- `in 5 days`
- `in 3 months`
- `in a year`


### 3 Detail

- Add task date properties for categorising dates, for example`task.due.category.groupText`
- Add task date properties for grouping dates by [time from now](https://momentjs.com/docs/#/displaying/fromnow/), for example `task.due.fromNow.groupText`
- Fully documentation all the available values in `TasksDate` class (instead of pointing users to the source code of that class on GitHub)
- Update documentation to add the new features
- Added tests

## Motivation and Context

<!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here. -->

- https://github.com/obsidian-tasks-group/obsidian-tasks/issues/2307
- https://github.com/obsidian-tasks-group/obsidian-tasks/discussions/2154

## 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. -->

- Running existing tests
- Adding new tests
- Manual exploratory testing in Tasks-Demo vault

## 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/Introduction.md b/docs/Introduction.md
index 829a2f96..d29f710a 100644
--- a/docs/Introduction.md
+++ b/docs/Introduction.md
@@ -7,6 +7,8 @@ publish: true
 ## What's New?
 
 - X.Y.Z: 🔥 Add [[Task Properties|task properties]] `task.priorityNameGroupText` and `task.status.typeGroupText`
+- X.Y.Z: 🔥 Add [[Task Properties#Values in TasksDate Properties|task date properties]] for categorising dates, for example`task.due.category.groupText`
+- X.Y.Z: 🔥 Add [[Task Properties#Values in TasksDate Properties|task date properties]] for grouping dates by [time from now](https://momentjs.com/docs/#/displaying/fromnow/), for example `task.due.fromNow.groupText`
 - 4.8.0: 🔥 Add [[Query Properties#Values for Query File Properties|query file properties]] `query.file.pathWithoutExtension` and `query.file.filenameWithoutExtension`
 - 4.8.0: 🔥 Add [[Task Properties#Values for File Properties|task file properties]] `task.file.pathWithoutExtension` and `task.file.filenameWithoutExtension`
 - 4.7.0: 🔥 Use [[Query Properties]] and [[Placeholders]] to filter and group with the query's file path, root, folder and name.
diff --git a/docs/Queries/Filters.md b/docs/Queries/Filters.md
index cd8d2866..b5b5a200 100644
--- a/docs/Queries/Filters.md
+++ b/docs/Queries/Filters.md
@@ -428,7 +428,7 @@ For more information, see [[Dates#Due date|Due date]].
 
 Since Tasks 4.2.0, **[[Custom Filters|custom filtering]] by due date** is now possible, using `task.due`.
 
-These examples all use `task.due` property, which is a `TasksDate` object. You can see the current [TasksDate source code](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/src/Scripting/TasksDate.ts), to explore its capabilities.
+These examples all use  `task.due` property, which is a `TasksDate` object. See [[Task Properties#Values in TasksDate Properties|Values in TasksDate Properties]] to explore its capabilities.
 
 Some of these examples use the [moment.js format characters](https://momentjs.com/docs/#/displaying/format/).
 
diff --git a/docs/Queries/Grouping.md b/docs/Queries/Grouping.md
index ed351c46..8587f016 100644
--- a/docs/Queries/Grouping.md
+++ b/docs/Queries/Grouping.md
@@ -150,12 +150,20 @@ Since Tasks 4.0.0, **[[Custom Grouping|custom grouping]] by next status symbol**
 
 Since Tasks 4.0.0, **[[Custom Grouping|custom grouping]] by due date** is now possible.
 
-These examples all use  `task.due` property, which is a `TasksDate` object. You can see the current [TasksDate source code](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/src/Scripting/TasksDate.ts), to explore its capabilities.
+These examples all use  `task.due` property, which is a `TasksDate` object. See [[Task Properties#Values in TasksDate Properties|Values in TasksDate Properties]] to explore its capabilities.
 
 Some of these examples use the [moment.js format characters](https://momentjs.com/docs/#/displaying/format/).
 
 <!-- placeholder to force blank line before included text --><!-- include: CustomGroupingExamples.test.dates_task.due_docs.approved.md -->
 
+- ```group by function task.due.category.groupText```
+  - Group task due dates in to 4 broad categories: `Overdue`, `Today`, `Future` and `Undated`, displayed in that order.
+  - Try this on a line before `group by due` if there are a lot of due date headings, and you would like them to be broken down in to some kind of structure.
+  - The values `task.due.category.name` and `task.due.category.sortOrder` are also available.
+- ```group by function task.due.fromNow.groupText```
+  - Group by the [time from now](https://momentjs.com/docs/#/displaying/fromnow/), for example `8 days ago`, `in 11 hours`.
+  - It users an empty string (so no heading) if there is no due date.
+  - The values `task.due.fromNow.name` and `task.due.fromNow.sortOrder` are also available.
 - ```group by function task.due.format("YYYY-MM-DD dddd")```
   - Like "group by due", except it uses no heading, instead of a heading "No due date", if there is no due date.
 - ```group by function task.due.formatAsDate()```
@@ -183,10 +191,6 @@ DON'T PANIC! For users who are comfortable with JavaScript, these more complicat
 
 <!-- placeholder to force blank line before included text --><!-- include: CustomGroupingExamples.test.dates_task.due.advanced_docs.approved.md -->
 
-- ```group by function task.due.moment?.fromNow() || ""```
-  - Group by the time from now, for example "8 days ago".
-  - Because Moment.fromNow() is not provided by TasksDate, we need special code for when there is no date value.
-  - Whilst interesting, the alphabetical sort order makes the headings a little hard to read.
 - ```group by function task.due.format("dddd")```
   - Group by day of the week (Monday, Tuesday, etc).
   - The day names are sorted alphabetically.
@@ -201,6 +205,7 @@ DON'T PANIC! For users who are comfortable with JavaScript, these more complicat
   - The key technique is to say that if the day is Sunday (`0`), then force it to be displayed as date number `8`, so it comes after the other days of the week.
   - Note that because we use variables to avoid repetition of values, we need to add `return`
 - ```group by function const date = task.due.moment; return (!date) ? '%%4%% Undated' : date.isBefore(moment(), 'day') ? '%%1%% Overdue' : date.isSame(moment(), 'day') ? '%%2%% Today' : '%%3%% Future'```
+  - This gives exactly the same output as `group by function task.due.category.groupText`, and is shown here in case you want to customise the behaviour in some way.
   - Group task due dates in to 4 broad categories: `Overdue`, `Today`, `Future` and `Undated`, displayed in that order.
   - Try this on a line before `group by due` if there are a lot of due date headings, and you would like them to be broken down in to some kind of structure.
   - A limitation of Tasks expressions is that they each need to fit on a single line, so this uses nested ternary operators, making it powerful but very hard to read.
diff --git a/docs/Scripting/Custom Grouping.md b/docs/Scripting/Custom Grouping.md
index 57ced5f5..73eea7d7 100644
--- a/docs/Scripting/Custom Grouping.md
+++ b/docs/Scripting/Custom Grouping.md
@@ -107,6 +107,14 @@ You can find many more examples by searching for `group by function` in the [[Gr
 
 <!-- placeholder to force blank line before included text --><!-- include: CustomGroupingExamples.test.dates_task.due_docs.approved.md -->
 
+- ```group by function task.due.category.groupText```
+  - Group task due dates in to 4 broad categories: `Overdue`, `Today`, `Future` and `Undated`, displayed in that order.
+  - Try this on a line before `group by due` if there are a lot of due date headings, and you would like them to be broken down in to some kind of structure.
+  - The values `task.due.category.name` and `task.due.category.sortOrder` are also available.
+- ```group by function task.due.fromNow.groupText```
+  - Group by the [time from now](https://momentjs.com/docs/#/displaying/fromnow/), for example `8 days ago`, `in 11 hours`.
+  - It users an empty string (so no heading) if there is no due date.
+  - The values `task.due.fromNow.name` and `task.due.fromNow.sortOrder` are also available.
 - ```group by function task.due.format("YYYY-MM-DD dddd")```
   - Like "group by due", except it uses no heading, instead of a heading "No due date", if there is no due date.
 - ```group by function task.due.formatAsDate()```
diff --git a/docs/Scripting/Task Properties.md b/docs/Scripting/Task Properties.md
index f0094f40..9e0a5874 100644
--- a/docs/Scripting/Task Properties.md
+++ b/docs/Scripting/Task Properties.md
@@ -52,12 +52,55 @@ For more information, including adding your own customised statuses, see [[Statu
 
 <!-- placeholder to force blank line after included text --><!-- endInclude -->
 
-1. You can see the current [TasksDate source code](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/src/Scripting/TasksDate.ts), to explore its capabilities.
-1. The `TasksDate` formatting methods use the [moment.js format characters](https://momentjs.com/docs/#/displaying/format/).
+1. Each of these values is a `TasksDate` object. The [[#Values in TasksDate Properties]] section below shows what can be done with them.
 1. Note that currently all stored dates have no time, or rather, their time is midnight at the start of the day, local time.
 1. For example uses of date properties, see [[Grouping#Due Date]].
 1. `task.happens` is the earlier of `task.due`, `task.scheduled` and `task.start`.
 
+## Values in TasksDate Properties
+
+<!-- placeholder to force blank line before included text --><!-- include: TaskProperties.test.task_date_fields.approved.md -->
+
+| Field | Type 1 | Example 1 | Type 2 | Example 2 |
+| ----- | ----- | ----- | ----- | ----- |
+| `task.due` | `TasksDate` | `2023-07-04 00:00` | `TasksDate` | `` |
+| `task.due.moment` | `Moment` | `moment('2023-07-04 00:00')` | `null` | `null` |
+| `task.due.formatAsDate()` | `string` | `'2023-07-04'` | `string` | `''` |
+| `task.due.formatAsDate('undated')` | `string` | `'2023-07-04'` | `string` | `'undated'` |
+| `task.due.formatAsDateAndTime()` | `string` | `'2023-07-04 00:00'` | `string` | `''` |
+| `task.due.formatAsDate('undated')` | `string` | `'2023-07-04'` | `string` | `'undated'` |
+| `task.due.format('dddd')` | `string` | `'Tuesday'` | `string` | `''` |
+| `task.due.toISOString()` | `string` | `'2023-07-04T00:00:00.000Z'` | `string` | `''` |
+| `task.due.toISOString(true)` | `string` | `'2023-07-04T00:00:00.000+00:00'` | `string` | `''` |
+| `task.due.category.name` | `string` | `'Future'` | `string` | `'Undated'` |
+| `task.due.category.sortOrder` | `number` | `3` | `number` | `4` |
+| `task.due.category.groupText` | `string` | `'%%3%% Future'` | `string` | `'%%4%% Undated'` |
+| `task.due.fromNow.name` | `string` | `'in 22 days'` | `string` | `''` |
+| `task.due.fromNow.sortOrder` | `number` | `320230704` | `number` | `0` |
+| `task.due.fromNow.groupText` | `string` | `'%%320230704%% in 22 days'` | `string` | `''` |
+
+<!-- placeholder to force blank line after included text --><!-- endInclude -->
+
+1. These examples refer to `task.due`, but they can be used on any of the date properties show in the section [[#Values for Dates in Tasks]] above.
+1. The `TasksDate` formatting methods use the [moment.js format characters](https://momentjs.com/docs/#/displaying/format/).
+1. You can see the current [TasksDate source code](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/src/Scripting/TasksDate.ts), to explore its implementation.
+1. `task.due.toISOString(true)` prevents UTC conversion - see the [moment documentation](https://momentjs.com/docs/#/displaying/as-iso-string/)
+1. `category` divides dates in to 4 named groups:
+    - `Overdue`
+    - `Today`
+    - `Future`
+    - `Undated`
+    - And they are numbered 1, 2, 3 and 4, in the order listed above.
+1. `fromNow` groups dates by the [time from now](https://momentjs.com/docs/#/displaying/fromnow/), for example:
+    - `2 months ago`
+    - `8 days ago`
+    - `in 11 hours`
+    - `in 5 days`
+    - `in 3 months`
+    - `in a year`
+1. The `category` properties were added in Tasks X.Y.Z.
+1. The `fromNow` properties were added in Tasks X.Y.Z.
+
 ## Values for Other Task Properties
 
 <!-- placeholder to force blank line before included text --><!-- include: TaskProperties.test.task_other_fields.approved.md -->
diff --git a/src/Scripting/TasksDate.ts b/src/Scripting/TasksDate.ts
index f641c301..8f066115 100644
--- a/src/Scripting/TasksDate.ts
+++ b/src/Scripting/TasksDate.ts
@@ -1,6 +1,29 @@
-import type { Moment } from 'moment';
+import type { DurationInputArg2, Moment } from 'moment';
 import { TaskRegularExpressions } from '../Task';
 
+// TODO Rename to PropertyCategory?
+// TODO Move to separate file
+// TODO Add tests
+// TODO Add JSdoc
+class Category {
+    public readonly name: string;
+    public readonly sortOrder: number;
+
+    // Pass in an empty name if you want groupText to be ''
+    constructor(name: string, number: number) {
+        this.name = name;
+        this.sortOrder = number;
+    }
+
+    public get groupText(): string {
+        if (this.name !== '') {
+            return `%%${this.sortOrder}%% ${this.name}`;
+        } else {
+            return '';
+        }
+    }
+}
+
 /**
  * TasksDate encapsulates a date, for simplifying the JavaScript expressions users need to
  * write in 'group by function' lines.
@@ -54,4 +77,58 @@ export class TasksDate {
     public toISOString(keepOffset?: boolean): string | null {
         return this._date ? this._date!.toISOString(keepOffset) : '';
     }
+
+    public get category(): Category {
+        const today = window.moment();
+        const date = this.moment;
+        if (!date) {
+            return new Category('Undated', 4);
+        }
+        if (date.isBefore(today, 'day')) {
+            return new Category('Overdue', 1);
+        }
+        if (date.isSame(today, 'day')) {
+            return new Category('Today', 2);
+        }
+        return new Category('Future', 3);
+    }
+
+    public get fromNow(): Category {
+        const date = this.moment;
+        if (!date) {
+            return new Category('', 0);
+        }
+        const order = this.fromNowOrder(date);
+        return new Category(date.fromNow(), order);
+    }
+
+    private fromNowOrder(date: moment.Moment) {
+        // Calculate a number that:
+        //   - is the same for all dates with the same 'fromNow()' name,
+        //   - sorts in ascending order of the date.
+
+        const now = window.moment();
+        const earlier = date.isSameOrBefore(now, 'day');
+        const startDateOfThisGroup = this.fromNowStartDateOfGroup(date, earlier, now);
+        const splitPastAndFutureDates = earlier ? 1 : 3;
+        return Number(splitPastAndFutureDates + startDateOfThisGroup.format('YYYYMMDD'));
+    }
+
+    private fromNowStartDateOfGroup(date: moment.Moment, earlier: boolean, now: any) {
+        // Calculate the earliest of all dates with the same 'fromNow()' name.
+
+        // https://momentjs.com/docs/#/displaying/fromnow/
+        // 'If you pass true, you can get the value without the suffix.'
+        const words = date.fromNow(true).split(' ');
+
+        let multiplier: number;
+        const word0AsNumber = Number(words[0]);
+        if (isNaN(word0AsNumber)) {
+            multiplier = 1; // examples: 'a year', 'a month', 'a day'
+        } else {
+            multiplier = word0AsNumber; // examples: '10 years', '6 months', '11 hours'
+        }
+        const unit = words[1] as DurationInputArg2; // day, days, weeks, month, year
+        return earlier ? now.subtract(multiplier, unit) : now.add(multiplier, unit);
+    }
 }


Test patch:
diff --git a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.dates_task.due.advanced_docs.approved.md b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.dates_task.due.advanced_docs.approved.md
index 6dc21041..c7b011df 100644
--- a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.dates_task.due.advanced_docs.approved.md
+++ b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.dates_task.due.advanced_docs.approved.md
@@ -1,9 +1,5 @@
 <!-- placeholder to force blank line before included text -->
 
-- ```group by function task.due.moment?.fromNow() || ""```
-    - Group by the time from now, for example "8 days ago".
-    - Because Moment.fromNow() is not provided by TasksDate, we need special code for when there is no date value.
-    - Whilst interesting, the alphabetical sort order makes the headings a little hard to read.
 - ```group by function task.due.format("dddd")```
     - Group by day of the week (Monday, Tuesday, etc).
     - The day names are sorted alphabetically.
@@ -18,6 +14,7 @@
     - The key technique is to say that if the day is Sunday (`0`), then force it to be displayed as date number `8`, so it comes after the other days of the week.
     - Note that because we use variables to avoid repetition of values, we need to add `return`
 - ```group by function const date = task.due.moment; return (!date) ? '%%4%% Undated' : date.isBefore(moment(), 'day') ? '%%1%% Overdue' : date.isSame(moment(), 'day') ? '%%2%% Today' : '%%3%% Future'```
+    - This gives exactly the same output as `group by function task.due.category.groupText`, and is shown here in case you want to customise the behaviour in some way.
     - Group task due dates in to 4 broad categories: `Overdue`, `Today`, `Future` and `Undated`, displayed in that order.
     - Try this on a line before `group by due` if there are a lot of due date headings, and you would like them to be broken down in to some kind of structure.
     - A limitation of Tasks expressions is that they each need to fit on a single line, so this uses nested ternary operators, making it powerful but very hard to read.
diff --git a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.dates_task.due.advanced_results.approved.txt b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.dates_task.due.advanced_results.approved.txt
index d0b6c4fa..7e22e263 100644
--- a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.dates_task.due.advanced_results.approved.txt
+++ b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.dates_task.due.advanced_results.approved.txt
@@ -2,18 +2,6 @@ Results of custom groupers
 
 
 
-group by function task.due.moment?.fromNow() || ""
-Group by the time from now, for example "8 days ago".
-Because Moment.fromNow() is not provided by TasksDate, we need special code for when there is no date value.
-Whilst interesting, the alphabetical sort order makes the headings a little hard to read.
-=>
-2 days ago
-20 hours ago
-in 4 hours
-Invalid date
-====================================================================================
-
-
 group by function task.due.format("dddd")
 Group by day of the week (Monday, Tuesday, etc).
 The day names are sorted alphabetically.
@@ -53,6 +41,7 @@ Undated
 
 
 group by function const date = task.due.moment; return (!date) ? '%%4%% Undated' : date.isBefore(moment(), 'day') ? '%%1%% Overdue' : date.isSame(moment(), 'day') ? '%%2%% Today' : '%%3%% Future'
+This gives exactly the same output as `group by function task.due.category.groupText`, and is shown here in case you want to customise the behaviour in some way.
 Group task due dates in to 4 broad categories: `Overdue`, `Today`, `Future` and `Undated`, displayed in that order.
 Try this on a line before `group by due` if there are a lot of due date headings, and you would like them to be broken down in to some kind of structure.
 A limitation of Tasks expressions is that they each need to fit on a single line, so this uses nested ternary operators, making it powerful but very hard to read.
diff --git a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.dates_task.due_docs.approved.md b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.dates_task.due_docs.approved.md
index 293f7e9a..9746a56e 100644
--- a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.dates_task.due_docs.approved.md
+++ b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.dates_task.due_docs.approved.md
@@ -1,5 +1,13 @@
 <!-- placeholder to force blank line before included text -->
 
+- ```group by function task.due.category.groupText```
+    - Group task due dates in to 4 broad categories: `Overdue`, `Today`, `Future` and `Undated`, displayed in that order.
+    - Try this on a line before `group by due` if there are a lot of due date headings, and you would like them to be broken down in to some kind of structure.
+    - The values `task.due.category.name` and `task.due.category.sortOrder` are also available.
+- ```group by function task.due.fromNow.groupText```
+    - Group by the [time from now](https://momentjs.com/docs/#/displaying/fromnow/), for example `8 days ago`, `in 11 hours`.
+    - It users an empty string (so no heading) if there is no due date.
+    - The values `task.due.fromNow.name` and `task.due.fromNow.sortOrder` are also available.
 - ```group by function task.due.format("YYYY-MM-DD dddd")```
     - Like "group by due", except it uses no heading, instead of a heading "No due date", if there is no due date.
 - ```group by function task.due.formatAsDate()```
diff --git a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.dates_task.due_results.approved.txt b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.dates_task.due_results.approved.txt
index 72739cb0..84b17f50 100644
--- a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.dates_task.due_results.approved.txt
+++ b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.dates_task.due_results.approved.txt
@@ -2,6 +2,30 @@ Results of custom groupers
 
 
 
+group by function task.due.category.groupText
+Group task due dates in to 4 broad categories: `Overdue`, `Today`, `Future` and `Undated`, displayed in that order.
+Try this on a line before `group by due` if there are a lot of due date headings, and you would like them to be broken down in to some kind of structure.
+The values `task.due.category.name` and `task.due.category.sortOrder` are also available.
+=>
+%%1%% Overdue
+%%2%% Today
+%%3%% Future
+%%4%% Undated
+====================================================================================
+
+
+group by function task.due.fromNow.groupText
+Group by the [time from now](https://momentjs.com/docs/#/displaying/fromnow/), for example `8 days ago`, `in 11 hours`.
+It users an empty string (so no heading) if there is no due date.
+The values `task.due.fromNow.name` and `task.due.fromNow.sortOrder` are also available.
+=>
+%%120230529%% 2 days ago
+%%120230531%% 20 hours ago
+%%320230531%% Invalid date
+%%320230601%% in 4 hours
+====================================================================================
+
+
 group by function task.due.format("YYYY-MM-DD dddd")
 Like "group by due", except it uses no heading, instead of a heading "No due date", if there is no due date.
 =>
diff --git a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.ts b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.ts
index ca507ec2..7a5b58a2 100644
--- a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.ts
+++ b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.ts
@@ -55,6 +55,18 @@ describe('dates', () => {
         [
             'task.due',
             [
+                [
+                    'group by function task.due.category.groupText',
+                    'Group task due dates in to 4 broad categories: `Overdue`, `Today`, `Future` and `Undated`, displayed in that order.',
+                    'Try this on a line before `group by due` if there are a lot of due date headings, and you would like them to be broken down in to some kind of structure.',
+                    'The values `task.due.category.name` and `task.due.category.sortOrder` are also available.',
+                ],
+                [
+                    'group by function task.due.fromNow.groupText',
+                    'Group by the [time from now](https://momentjs.com/docs/#/displaying/fromnow/), for example `8 days ago`, `in 11 hours`.',
+                    'It users an empty string (so no heading) if there is no due date.',
+                    'The values `task.due.fromNow.name` and `task.due.fromNow.sortOrder` are also available.',
+                ],
                 [
                     'group by function task.due.format("YYYY-MM-DD dddd")',
                     'Like "group by due", except it uses no heading, instead of a heading "No due date", if there is no due date',
@@ -92,12 +104,6 @@ describe('dates', () => {
         [
             'task.due.advanced',
             [
-                [
-                    'group by function task.due.moment?.fromNow() || ""',
-                    'Group by the time from now, for example "8 days ago".',
-                    'Because Moment.fromNow() is not provided by TasksDate, we need special code for when there is no date value.',
-                    'Whilst interesting, the alphabetical sort order makes the headings a little hard to read',
-                ],
                 [
                     'group by function task.due.format("dddd")',
                     'Group by day of the week (Monday, Tuesday, etc).',
@@ -119,6 +125,7 @@ describe('dates', () => {
                 ],
                 [
                     "group by function const date = task.due.moment; return (!date) ? '%%4%% Undated' : date.isBefore(moment(), 'day') ? '%%1%% Overdue' : date.isSame(moment(), 'day') ? '%%2%% Today' : '%%3%% Future'",
+                    'This gives exactly the same output as `group by function task.due.category.groupText`, and is shown here in case you want to customise the behaviour in some way',
                     'Group task due dates in to 4 broad categories: `Overdue`, `Today`, `Future` and `Undated`, displayed in that order.',
                     'Try this on a line before `group by due` if there are a lot of due date headings, and you would like them to be broken down in to some kind of structure.',
                     'A limitation of Tasks expressions is that they each need to fit on a single line, so this uses nested ternary operators, making it powerful but very hard to read.',
diff --git a/tests/Scripting/TaskProperties.test.task_date_fields.approved.md b/tests/Scripting/TaskProperties.test.task_date_fields.approved.md
new file mode 100644
index 00000000..cc121a1b
--- /dev/null
+++ b/tests/Scripting/TaskProperties.test.task_date_fields.approved.md
@@ -0,0 +1,22 @@
+<!-- placeholder to force blank line before included text -->
+
+| Field | Type 1 | Example 1 | Type 2 | Example 2 |
+| ----- | ----- | ----- | ----- | ----- |
+| `task.due` | `TasksDate` | `2023-07-04 00:00` | `TasksDate` | `` |
+| `task.due.moment` | `Moment` | `moment('2023-07-04 00:00')` | `null` | `null` |
+| `task.due.formatAsDate()` | `string` | `'2023-07-04'` | `string` | `''` |
+| `task.due.formatAsDate('undated')` | `string` | `'2023-07-04'` | `string` | `'undated'` |
+| `task.due.formatAsDateAndTime()` | `string` | `'2023-07-04 00:00'` | `string` | `''` |
+| `task.due.formatAsDate('undated')` | `string` | `'2023-07-04'` | `string` | `'undated'` |
+| `task.due.format('dddd')` | `string` | `'Tuesday'` | `string` | `''` |
+| `task.due.toISOString()` | `string` | `'2023-07-04T00:00:00.000Z'` | `string` | `''` |
+| `task.due.toISOString(true)` | `string` | `'2023-07-04T00:00:00.000+00:00'` | `string` | `''` |
+| `task.due.category.name` | `string` | `'Future'` | `string` | `'Undated'` |
+| `task.due.category.sortOrder` | `number` | `3` | `number` | `4` |
+| `task.due.category.groupText` | `string` | `'%%3%% Future'` | `string` | `'%%4%% Undated'` |
+| `task.due.fromNow.name` | `string` | `'in 22 days'` | `string` | `''` |
+| `task.due.fromNow.sortOrder` | `number` | `320230704` | `number` | `0` |
+| `task.due.fromNow.groupText` | `string` | `'%%320230704%% in 22 days'` | `string` | `''` |
+
+
+<!-- placeholder to force blank line after included text -->
diff --git a/tests/Scripting/TaskProperties.test.ts b/tests/Scripting/TaskProperties.test.ts
index 85f5435b..b0ed6781 100644
--- a/tests/Scripting/TaskProperties.test.ts
+++ b/tests/Scripting/TaskProperties.test.ts
@@ -65,6 +65,26 @@ describe('task', () => {
         ]);
     });
 
+    it('date fields', () => {
+        verifyFieldDataForReferenceDocs([
+            'task.due',
+            'task.due.moment',
+            'task.due.formatAsDate()',
+            "task.due.formatAsDate('undated')",
+            'task.due.formatAsDateAndTime()',
+            "task.due.formatAsDate('undated')",
+            "task.due.format('dddd')",
+            'task.due.toISOString()',
+            'task.due.toISOString(true)', // https://momentjs.com/docs/#/displaying/as-iso-string/ - true prevents UTC conversion
+            'task.due.category.name',
+            'task.due.category.sortOrder',
+            'task.due.category.groupText',
+            'task.due.fromNow.name',
+            'task.due.fromNow.sortOrder',
+            'task.due.fromNow.groupText',
+        ]);
+    });
+
     it('other fields', () => {
         verifyFieldDataForReferenceDocs([
             'task.description',
diff --git a/tests/Scripting/TasksDate.test.ts b/tests/Scripting/TasksDate.test.ts
index 99e5e16d..5525372e 100644
--- a/tests/Scripting/TasksDate.test.ts
+++ b/tests/Scripting/TasksDate.test.ts
@@ -17,6 +17,11 @@ afterEach(() => {
     jest.useRealTimers();
 });
 
+const overdue = new TasksDate(moment('2023-06-10'));
+const today = new TasksDate(moment('2023-06-11'));
+const future = new TasksDate(moment('2023-06-12'));
+const undated = new TasksDate(null);
+
 describe('TasksDate', () => {
     it('should format valid dates', () => {
         const date = '2023-10-13';
@@ -50,4 +55,53 @@ describe('TasksDate', () => {
         expect(tasksDate.formatAsDateAndTime()).toEqual('Invalid date');
         expect(tasksDate.toISOString()).toBeNull();
     });
+
+    it('should categorise dates for grouping, relative to today - with Category object', () => {
+        expect(overdue.category.groupText).toEqual('%%1%% Overdue');
+        expect(today.category.groupText).toEqual('%%2%% Today');
+        expect(future.category.groupText).toEqual('%%3%% Future');
+        expect(undated.category.groupText).toEqual('%%4%% Undated');
+    });
+
+    // For behaviour, see https://momentjs.com/docs/#/displaying/fromnow/
+    it.each([
+        ['1899-04-30T18:44:54.240Z', '%%118990611%% 124 years ago'],
+        ['2021-01-20T07:04:35.022Z', '%%120210611%% 2 years ago'],
+        ['2022-07-25T08:40:50.048Z', '%%120220611%% a year ago'],
+        ['2022-07-29T19:07:44.595Z', '%%120220811%% 10 months ago'],
+        ['2023-03-29T08:31:15.671Z', '%%120230411%% 2 months ago'],
+        ['2023-04-29T08:59:53.331Z', '%%120230511%% a month ago'],
+        ['2023-05-17T14:42:26.842Z', '%%120230517%% 25 days ago'],
+        ['2023-06-09T17:36:01.176Z', '%%120230609%% 2 days ago'],
+        ['2023-06-11T09:42:21.083Z', '%%120230611%% 10 hours ago'],
+        ['2023-06-11T19:15:00.000Z', '%%120230611%% an hour ago'],
+        ['2023-06-11T19:16:00.000Z', '%%120230611%% 44 minutes ago'],
+        ['2023-06-11T19:59:00.000Z', '%%120230611%% a minute ago'],
+        ['2023-06-11T20:00:00.000Z', '%%120230611%% a few seconds ago'], // Exact time that clock is set to
+        ['2023-06-11T20:01:00.000Z', '%%120230611%% in a minute'],
+        ['2023-06-11T20:44:00.000Z', '%%120230611%% in 44 minutes'],
+        ['2023-06-11T20:45:00.000Z', '%%120230611%% in an hour'],
+        ['2023-06-12T10:56:01.641Z', '%%320230612%% in 15 hours'],
+        ['2023-06-12T22:02:39.934Z', '%%320230612%% in a day'],
+        ['2023-06-15T13:07:53.700Z', '%%320230615%% in 4 days'],
+        ['2023-07-06T07:41:58.379Z', '%%320230705%% in 24 days'],
+        ['2023-07-08T02:55:31.854Z', '%%320230711%% in a month'],
+        ['2024-04-03T20:06:33.578Z', '%%320240411%% in 10 months'],
+        ['2024-05-02T03:53:43.120Z', '%%320240611%% in a year'],
+        ['2024-12-14T08:15:40.973Z', '%%320250611%% in 2 years'],
+        ['2032-12-25T14:41:16.452Z', '%%320330611%% in 10 years'],
+    ])(
+        'should categorise dates for grouping, relative to today: on "%s" - expected "%s"',
+        (date: string, expectedResult: string) => {
+            const tasksDate = new TasksDate(moment(date));
+            expect(tasksDate.fromNow.groupText).toEqual(expectedResult);
+        },
+    );
+
+    it('should categorise edge-case dates for grouping, relative to today', () => {
+        expect(new TasksDate(null).fromNow.groupText).toEqual('');
+        // Invalid dates always get sorted next to current date
+        expect(new TasksDate(moment('1999-02-31')).fromNow.groupText).toEqual('%%320230611%% Invalid date');
+        expect(new TasksDate(moment('2023-02-31')).fromNow.groupText).toEqual('%%320230611%% Invalid date');
+    });
 });