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

Issue:
`task.due.category`, `task.due.fromNow` and similar should put invalid dates in a heading `Invalid date`, not `Future`
### Please check that this issue hasn't been reported before.

- [X] I searched previous [Bug Reports](https://github.com/obsidian-tasks-group/obsidian-tasks/issues?q=is%3Aopen+is%3Aissue+label%3A%22type%3A+bug%22+sort%3Acomments-desc) didn't find any similar reports.

### Expected Behavior

_Note: This issue refers to `due` dates, but the same applies to all the date fields._

Note: This follows on from #2589 and #2591.

This issue relates to the following documentation in Tasks: [Group by Dates in Tasks > Due Date](https://publish.obsidian.md/tasks/Queries/Grouping#Due+Date):

---


```javascript
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.


---


All uses of dates in scripting should acknowledge invalid dates, and prioritise them over valid and absent dates.


### Current behaviour

In the custom grouping and scripting examples, invalid dates are not mentioned.

And they are treated the same as future dates.



### Steps to reproduce


1. Create a note, and call it `Invalid dates with Sorting and Grouping`
2. View it in an editing mode
3. Use `Paste and Match Style` in the following content:

~~~
# Invalid dates with Sorting and Grouping

## Tasks

Numbers indicate the sort order I feel is useful, as invalid dates require action to fix them, so they should be prioritised.

- [ ] #task 1 invalid due date 📅 1999-02-32
- [ ] #task 2 valid over-due date 📅 2023-01-01
- [ ] #task 3 valid future-due date 📅 2050-01-01
- [ ] #task 4 undated


## Group by due - custom function

A heading `Invalid date` should be the first heading, with the invalid task inside it.

```tasks
group by function task.due.category.groupText

path includes {{query.file.path}}
hide backlinks
hide postpone button
hide task count
```

## Sort by due - custom function

The invalid task should be before the dated ones.

```tasks
sort by function task.due.category.groupText

path includes {{query.file.path}}
hide backlinks
hide postpone button
hide task count
```
~~~

**Actual behaviour**

![image](https://github.com/obsidian-tasks-group/obsidian-tasks/assets/4840096/308100de-e001-4cff-b481-0a83b41529e5)


**Expected behaviour for the sort case**

![image](https://github.com/obsidian-tasks-group/obsidian-tasks/assets/4840096/2f4f1959-0f0e-4304-9b63-81fb0aea19c9)


### Which Operating Systems are you using?

- [ ] Android
- [ ] iPhone/iPad
- [ ] Linux
- [ ] macOS
- [ ] Windows

### Obsidian Version

1.5.3

### Tasks Plugin Version

5.6.0

### Checks

- [ ] I have tried it with all other plugins disabled and the error still occurs

### Possible solution

The behaviour is going to include changes to:

- `TasksDate.category()`
- `TasksDate.fromNow()`
- [Filters examples](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/docs/Queries/Filters.md)
- [Sorting examples](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/cad612bc17008fa8826dc219699fe46629f8518d/docs/Queries/Sorting.md)
- [Grouping examples](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/4760e1dd1f34cd25823920fe2d88320e8f6f7e32/docs/Queries/Grouping.md)

Pull request:
Fix undated tasks categories
<!--- Provide a general summary of your changes in the Title above -->

# Description

- Add a 5th category, `Invalid date` to `task.due.category` - it is number 0, so comes before all other dates in `sort by function` and `group by function` with dates.

## 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. -->

Fixes #2592.

## 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. -->

- Automated tests
- Exploratory testing

## Screenshots (if appropriate)

![image](https://github.com/obsidian-tasks-group/obsidian-tasks/assets/4840096/50822612-a9cf-47b8-a6cb-5138a20bb9a7)


## 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] **Bug fix** (prefix: `fix` - non-breaking change which fixes an issue)
- [x] **Documentation** (prefix: `docs` - improvements to any documentation content **for users**)
- [x] **Sample vault** (prefix: `vault` - improvements to the [Tasks-Demo sample vault](https://github.com/obsidian-tasks-group/obsidian-tasks/tree/main/resources/sample_vaults/Tasks-Demo))

Internal changes:

- [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/Filters.md b/docs/Queries/Filters.md
index 90e57342..9138dccf 100644
--- a/docs/Queries/Filters.md
+++ b/docs/Queries/Filters.md
@@ -494,6 +494,15 @@ For users who are comfortable with JavaScript, these more complicated examples m
 
 <!-- placeholder to force blank line before included text --><!-- include: CustomFilteringExamples.test.dates_task.due.advanced_docs.approved.md -->
 
+```javascript
+filter by function \
+    const date = task.due.moment; \
+    return date ? !date.isValid() : false;
+```
+
+- Like `due date is invalid`.
+- It matches tasks that have a due date and the due date is invalid, such as `2022-13-32`
+
 ```javascript
 filter by function task.due.moment?.isSameOrBefore(moment(), 'day') || false
 ```
diff --git a/docs/Queries/Grouping.md b/docs/Queries/Grouping.md
index 3a930f0c..2eba98cc 100644
--- a/docs/Queries/Grouping.md
+++ b/docs/Queries/Grouping.md
@@ -185,7 +185,7 @@ Some of these examples use the [moment.js format characters](https://momentjs.co
 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.
+- Group task due dates in to 5 broad categories: `Invalid date`, `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.
 
@@ -281,14 +281,15 @@ group by function                                   \
 group by function \
     const date = task.due.moment; \
     return \
-        (!date)                           ? '%%4%% Undated' : \
-        date.isBefore(moment(), 'day')    ? '%%1%% Overdue' : \
-        date.isSame(moment(), 'day')      ? '%%2%% Today'   : \
+        (!date)                           ? '%%4%% Undated' :      \
+        !date.isValid()                   ? '%%0%% Invalid date' : \
+        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.
+- Group task due dates in to 5 broad categories: `Invalid date`, `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.
 - Note that because we use variables to avoid repetition of values, we need to add `return`
 
@@ -296,13 +297,14 @@ group by function \
 group by function \
     const date = task.due.moment; \
     return \
-        (!date)                           ? '%%4%% ==Undated==' : \
-        date.isBefore(moment(), 'day')    ? '%%1%% ==Overdue==' : \
-        date.isSame(moment(), 'day')      ? '%%2%% ==Today=='   : \
+        (!date)                           ? '%%4%% ==Undated==' :      \
+        !date.isValid()                   ? '%%0%% ==Invalid date==' : \
+        date.isBefore(moment(), 'day')    ? '%%1%% ==Overdue==' :      \
+        date.isSame(moment(), 'day')      ? '%%2%% ==Today=='   :      \
         '%%3%% ==Future==';
 ```
 
-- As above, but the headings `Overdue`, `Today`, `Future` and `Undated` are highlighted.
+- As above, but the headings `Invalid date`, `Overdue`, `Today`, `Future` and `Undated` are highlighted.
 - See the sample screenshot below.
 
 ```javascript
@@ -311,6 +313,7 @@ group by function \
     const now = moment(); \
     const label = (order, name) => `%%${order}%% ==${name}==`; \
     if (!date)                      return label(4, 'Undated'); \
+    if (!date.isValid())            return label(0, 'Invalid date'); \
     if (date.isBefore(now, 'day'))  return label(1, 'Overdue'); \
     if (date.isSame(now, 'day'))    return label(2, 'Today'); \
     return label(3, 'Future');
diff --git a/docs/Scripting/Custom Filters.md b/docs/Scripting/Custom Filters.md
index d8d4c769..ea3fafdd 100644
--- a/docs/Scripting/Custom Filters.md
+++ b/docs/Scripting/Custom Filters.md
@@ -97,6 +97,15 @@ For users who are comfortable with JavaScript, these more complicated examples m
 
 <!-- placeholder to force blank line before included text --><!-- include: CustomFilteringExamples.test.dates_task.due.advanced_docs.approved.md -->
 
+```javascript
+filter by function \
+    const date = task.due.moment; \
+    return date ? !date.isValid() : false;
+```
+
+- Like `due date is invalid`.
+- It matches tasks that have a due date and the due date is invalid, such as `2022-13-32`
+
 ```javascript
 filter by function task.due.moment?.isSameOrBefore(moment(), 'day') || false
 ```
diff --git a/docs/Scripting/Custom Grouping.md b/docs/Scripting/Custom Grouping.md
index d7566862..a80b2406 100644
--- a/docs/Scripting/Custom Grouping.md
+++ b/docs/Scripting/Custom Grouping.md
@@ -127,7 +127,7 @@ group by function task.description.replace('short', '==short==')
 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.
+- Group task due dates in to 5 broad categories: `Invalid date`, `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.
 
diff --git a/docs/Scripting/Task Properties.md b/docs/Scripting/Task Properties.md
index 0a611982..e1bccd0c 100644
--- a/docs/Scripting/Task Properties.md
+++ b/docs/Scripting/Task Properties.md
@@ -94,12 +94,13 @@ For more information, including adding your own customised statuses, see [[Statu
     - an empty string `''` or `""`, meaning 'do not add a heading for tasks missing this date property'.
 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:
+1. `category` divides dates in to 5 named groups:
+    - `Invalid date`
     - `Overdue`
     - `Today`
     - `Future`
     - `Undated`
-    - And they are numbered 1, 2, 3 and 4, in the order listed above.
+    - And they are numbered 0, 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`
@@ -108,6 +109,7 @@ For more information, including adding your own customised statuses, see [[Statu
     - `in 3 months`
     - `in a year`
 1. The `category` properties were added in Tasks 4.9.0.
+    - The `Invalid date` category was added in Tasks X.Y.Z.
 1. The `fromNow` properties were added in Tasks 4.9.0.
 
 ## Values for Other Task Properties
diff --git a/resources/sample_vaults/Tasks-Demo/.obsidian/appearance.json b/resources/sample_vaults/Tasks-Demo/.obsidian/appearance.json
index ff31f0e4..1a555150 100644
--- a/resources/sample_vaults/Tasks-Demo/.obsidian/appearance.json
+++ b/resources/sample_vaults/Tasks-Demo/.obsidian/appearance.json
@@ -1,5 +1,7 @@
 {
   "theme": "moonstone",
-  "enabledCssSnippets": [],
+  "enabledCssSnippets": [
+    "tasks-code-block-blue-border"
+  ],
   "accentColor": ""
 }
\ No newline at end of file
diff --git a/src/Scripting/TasksDate.ts b/src/Scripting/TasksDate.ts
index a9384c3f..c2b5a3c6 100644
--- a/src/Scripting/TasksDate.ts
+++ b/src/Scripting/TasksDate.ts
@@ -69,6 +69,9 @@ export class TasksDate {
         if (date.isSame(today, 'day')) {
             return new PropertyCategory('Today', 2);
         }
+        if (!date.isValid()) {
+            return new PropertyCategory('Invalid date', 0);
+        }
         return new PropertyCategory('Future', 3);
     }
 
@@ -82,6 +85,11 @@ export class TasksDate {
     }
 
     private fromNowOrder(date: moment.Moment) {
+        // Always put invalid dates first:
+        if (!date.isValid()) {
+            return 0;
+        }
+
         // Calculate a number that:
         //   - is the same for all dates with the same 'fromNow()' name,
         //   - sorts in ascending order of the date.


Test patch:
diff --git a/resources/sample_vaults/Tasks-Demo/Manual Testing/Invalid dates with Sorting and Grouping.md b/resources/sample_vaults/Tasks-Demo/Manual Testing/Invalid dates with Sorting and Grouping.md
index f44a00f0..c48b4287 100644
--- a/resources/sample_vaults/Tasks-Demo/Manual Testing/Invalid dates with Sorting and Grouping.md
+++ b/resources/sample_vaults/Tasks-Demo/Manual Testing/Invalid dates with Sorting and Grouping.md
@@ -28,9 +28,9 @@ hide task count
 
 ### Group by due - custom function
 
-A heading `Invalid` should be the first heading, with the invalid task inside it.
+A heading `Invalid date` is the first heading, with the invalid task inside it.
 
-Bug report: **`task.due.category.groupText` and similar should put tasks with invalid dates in a heading `Invalid` or `Invalid date`, not `Future`.**
+==FIXED==: [Bug report](https://github.com/obsidian-tasks-group/obsidian-tasks/issues/2592): **`task.due.category.groupText` and similar should put tasks with invalid dates in a heading `Invalid` or `Invalid date`, not `Future`.**
 
 ```tasks
 group by function task.due.category.groupText
@@ -60,9 +60,9 @@ hide task count
 
 ### Sort by due - custom function
 
-The invalid task should be before the dated ones.
+The invalid task is before the dated ones.
 
-Bug report: **`sort by function task.due.category.groupText` and similar should put tasks with invalid dates before the tasks with valid dates - same for `task.due.category.sortOrder`.**
+==FIXED==: [Bug report](https://github.com/obsidian-tasks-group/obsidian-tasks/issues/2592): **`sort by function task.due.category.groupText` and similar should put tasks with invalid dates before the tasks with valid dates - same for `task.due.category.sortOrder`.**
 
 ```tasks
 sort by function task.due.category.groupText
diff --git a/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.dates_task.due.advanced_docs.approved.md b/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.dates_task.due.advanced_docs.approved.md
index d10cdb6c..afbbb1fd 100644
--- a/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.dates_task.due.advanced_docs.approved.md
+++ b/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.dates_task.due.advanced_docs.approved.md
@@ -1,6 +1,15 @@
 <!-- placeholder to force blank line before included text -->
 
 
+```javascript
+filter by function \
+    const date = task.due.moment; \
+    return date ? !date.isValid() : false;
+```
+
+- Like `due date is invalid`.
+- It matches tasks that have a due date and the due date is invalid, such as `2022-13-32`
+
 ```javascript
 filter by function task.due.moment?.isSameOrBefore(moment(), 'day') || false
 ```
diff --git a/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.dates_task.due.advanced_results.approved.txt b/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.dates_task.due.advanced_results.approved.txt
index ff19022f..ae28e4e1 100644
--- a/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.dates_task.due.advanced_results.approved.txt
+++ b/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.dates_task.due.advanced_results.approved.txt
@@ -2,6 +2,16 @@ Results of custom filters
 
 
 
+filter by function \
+    const date = task.due.moment; \
+    return date ? !date.isValid() : false;
+Like `due date is invalid`.
+It matches tasks that have a due date and the due date is invalid, such as `2022-13-32`
+=>
+- [ ] my description 📅 Invalid date
+====================================================================================
+
+
 filter by function task.due.moment?.isSameOrBefore(moment(), 'day') || false
 Find all tasks due today or earlier.
 `moment()` returns the current date and time, which we need to convert to the start of the day.
diff --git a/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.ts b/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.ts
index 687467a7..2a9d5c3e 100644
--- a/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.ts
+++ b/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.ts
@@ -83,6 +83,14 @@ describe('dates', () => {
         [
             'task.due.advanced',
             [
+                [
+                    // comment to force line break
+                    `filter by function \\
+    const date = task.due.moment; \\
+    return date ? !date.isValid() : false;`,
+                    'Like `due date is invalid`.',
+                    'It matches tasks that have a due date and the due date is invalid, such as `2022-13-32`',
+                ],
                 [
                     "filter by function task.due.moment?.isSameOrBefore(moment(), 'day') || false",
                     'Find all tasks due today or earlier.',
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 952d8185..e7f5074c 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
@@ -39,14 +39,15 @@ group by function                                   \
 group by function \
     const date = task.due.moment; \
     return \
-        (!date)                           ? '%%4%% Undated' : \
-        date.isBefore(moment(), 'day')    ? '%%1%% Overdue' : \
-        date.isSame(moment(), 'day')      ? '%%2%% Today'   : \
+        (!date)                           ? '%%4%% Undated' :      \
+        !date.isValid()                   ? '%%0%% Invalid date' : \
+        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.
+- Group task due dates in to 5 broad categories: `Invalid date`, `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.
 - Note that because we use variables to avoid repetition of values, we need to add `return`
 
@@ -54,13 +55,14 @@ group by function \
 group by function \
     const date = task.due.moment; \
     return \
-        (!date)                           ? '%%4%% ==Undated==' : \
-        date.isBefore(moment(), 'day')    ? '%%1%% ==Overdue==' : \
-        date.isSame(moment(), 'day')      ? '%%2%% ==Today=='   : \
+        (!date)                           ? '%%4%% ==Undated==' :      \
+        !date.isValid()                   ? '%%0%% ==Invalid date==' : \
+        date.isBefore(moment(), 'day')    ? '%%1%% ==Overdue==' :      \
+        date.isSame(moment(), 'day')      ? '%%2%% ==Today=='   :      \
         '%%3%% ==Future==';
 ```
 
-- As above, but the headings `Overdue`, `Today`, `Future` and `Undated` are highlighted.
+- As above, but the headings `Invalid date`, `Overdue`, `Today`, `Future` and `Undated` are highlighted.
 - See the sample screenshot below.
 
 ```javascript
@@ -69,6 +71,7 @@ group by function \
     const now = moment(); \
     const label = (order, name) => `%%${order}%% ==${name}==`; \
     if (!date)                      return label(4, 'Undated'); \
+    if (!date.isValid())            return label(0, 'Invalid date'); \
     if (date.isBefore(now, 'day'))  return label(1, 'Overdue'); \
     if (date.isSame(now, 'day'))    return label(2, 'Today'); \
     return label(3, 'Future');
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 ea283d90..f003a71c 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
@@ -52,15 +52,17 @@ 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'   : \
+        (!date)                           ? '%%4%% Undated' :      \
+        !date.isValid()                   ? '%%0%% Invalid date' : \
+        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.
+Group task due dates in to 5 broad categories: `Invalid date`, `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.
 Note that because we use variables to avoid repetition of values, we need to add `return`
 =>
+%%0%% Invalid date
 %%1%% Overdue
 %%2%% Today
 %%3%% Future
@@ -71,13 +73,15 @@ Note that because we use variables to avoid repetition of values, we need to add
 group by function \
     const date = task.due.moment; \
     return \
-        (!date)                           ? '%%4%% ==Undated==' : \
-        date.isBefore(moment(), 'day')    ? '%%1%% ==Overdue==' : \
-        date.isSame(moment(), 'day')      ? '%%2%% ==Today=='   : \
+        (!date)                           ? '%%4%% ==Undated==' :      \
+        !date.isValid()                   ? '%%0%% ==Invalid date==' : \
+        date.isBefore(moment(), 'day')    ? '%%1%% ==Overdue==' :      \
+        date.isSame(moment(), 'day')      ? '%%2%% ==Today=='   :      \
         '%%3%% ==Future==';
-As above, but the headings `Overdue`, `Today`, `Future` and `Undated` are highlighted.
+As above, but the headings `Invalid date`, `Overdue`, `Today`, `Future` and `Undated` are highlighted.
 See the sample screenshot below.
 =>
+%%0%% ==Invalid date==
 %%1%% ==Overdue==
 %%2%% ==Today==
 %%3%% ==Future==
@@ -90,11 +94,13 @@ group by function \
     const now = moment(); \
     const label = (order, name) => `%%${order}%% ==${name}==`; \
     if (!date)                      return label(4, 'Undated'); \
+    if (!date.isValid())            return label(0, 'Invalid date'); \
     if (date.isBefore(now, 'day'))  return label(1, 'Overdue'); \
     if (date.isSame(now, 'day'))    return label(2, 'Today'); \
     return label(3, 'Future');
 As above, but using a local function, and `if` statements.
 =>
+%%0%% ==Invalid date==
 %%1%% ==Overdue==
 %%2%% ==Today==
 %%3%% ==Future==
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 c38647d4..9e0f32ed 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
@@ -5,7 +5,7 @@
 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.
+- Group task due dates in to 5 broad categories: `Invalid date`, `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.
 
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 84b17f50..da8a5e76 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
@@ -3,10 +3,11 @@ 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.
+Group task due dates in to 5 broad categories: `Invalid date`, `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.
 =>
+%%0%% Invalid date
 %%1%% Overdue
 %%2%% Today
 %%3%% Future
@@ -19,9 +20,9 @@ Group by the [time from now](https://momentjs.com/docs/#/displaying/fromnow/), f
 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.
 =>
+%%0%% Invalid date
 %%120230529%% 2 days ago
 %%120230531%% 20 hours ago
-%%320230531%% Invalid date
 %%320230601%% in 4 hours
 ====================================================================================
 
diff --git a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.ts b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.ts
index 8b2f03c2..da233d17 100644
--- a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.ts
+++ b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.ts
@@ -70,7 +70,7 @@ describe('dates', () => {
             [
                 [
                     '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.',
+                    'Group task due dates in to 5 broad categories: `Invalid date`, `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.',
                 ],
@@ -149,12 +149,13 @@ 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'   : \\
+        (!date)                           ? '%%4%% Undated' :      \\
+        !date.isValid()                   ? '%%0%% Invalid date' : \\
+        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.',
+                    'Group task due dates in to 5 broad categories: `Invalid date`, `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.',
                     'Note that because we use variables to avoid repetition of values, we need to add `return`',
                 ],
@@ -162,11 +163,12 @@ 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=='   : \\
+        (!date)                           ? '%%4%% ==Undated==' :      \\
+        !date.isValid()                   ? '%%0%% ==Invalid date==' : \\
+        date.isBefore(moment(), 'day')    ? '%%1%% ==Overdue==' :      \\
+        date.isSame(moment(), 'day')      ? '%%2%% ==Today=='   :      \\
         '%%3%% ==Future==';`,
-                    'As above, but the headings `Overdue`, `Today`, `Future` and `Undated` are highlighted.',
+                    'As above, but the headings `Invalid date`, `Overdue`, `Today`, `Future` and `Undated` are highlighted.',
                     'See the sample screenshot below',
                 ],
                 [
@@ -175,6 +177,7 @@ describe('dates', () => {
     const now = moment(); \\
     const label = (order, name) => \`%%\${order}%% ==\${name}==\`; \\
     if (!date)                      return label(4, 'Undated'); \\
+    if (!date.isValid())            return label(0, 'Invalid date'); \\
     if (date.isBefore(now, 'day'))  return label(1, 'Overdue'); \\
     if (date.isSame(now, 'day'))    return label(2, 'Today'); \\
     return label(3, 'Future');`,
diff --git a/tests/Scripting/TasksDate.test.ts b/tests/Scripting/TasksDate.test.ts
index f4f1936c..97956ad3 100644
--- a/tests/Scripting/TasksDate.test.ts
+++ b/tests/Scripting/TasksDate.test.ts
@@ -21,6 +21,7 @@ afterEach(() => {
 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 invalid = new TasksDate(moment('2023-02-31'));
 const undated = new TasksDate(null);
 
 describe('TasksDate', () => {
@@ -58,6 +59,7 @@ describe('TasksDate', () => {
     });
 
     it('should categorise dates for grouping, relative to today - with PropertyCategory object', () => {
+        expect(invalid.category.groupText).toEqual('%%0%% Invalid date');
         expect(overdue.category.groupText).toEqual('%%1%% Overdue');
         expect(today.category.groupText).toEqual('%%2%% Today');
         expect(future.category.groupText).toEqual('%%3%% Future');
@@ -101,9 +103,9 @@ describe('TasksDate', () => {
 
     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');
+        // Invalid dates always get put first
+        expect(new TasksDate(moment('1999-02-31')).fromNow.groupText).toEqual('%%0%% Invalid date');
+        expect(new TasksDate(moment('2023-02-31')).fromNow.groupText).toEqual('%%0%% Invalid date');
     });
 });