Repository: obsidian-tasks-group/obsidian-tasks
Pull request #2032 (2023-06-12 10:03:45)

feat: Add TasksDate class for use in 'group by function'
<!--- Provide a general summary of your changes in the Title above -->

# Description

- Introduce `TasksDate` class as a wrapper around `Moment | null`
- The dates used by the scripting code (`Task.due`, `Task.happens` etc all now return a `TasksDate` object
- All `group by function` examples for date properties now updated

## Motivation and Context

To not have to explain the [ternary operator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Conditional_operator) for the normal case of formatting dates in group headings.

## How has this been tested?

- By running the existing tests

<!--- 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. -->

## 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] **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.
- [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/Grouping.md b/docs/Queries/Grouping.md
index c52d12ed..0b5c0949 100644
--- a/docs/Queries/Grouping.md
+++ b/docs/Queries/Grouping.md
@@ -157,10 +157,10 @@ Since Tasks X.Y.Z, **custom grouping by done date** is now possible.
 <!-- placeholder to force blank line before included text --> <!-- include: TaskPropertyExamples.test.custom_grouping_by_task.done_docs.approved.md -->
 
 ~~~text
-group by function task.done?.format("YYYY-MM-DD dddd") || ""
+group by function task.done.format("YYYY-MM-DD dddd")
 ~~~
 
-- Like "group by task.done", except it does not write "No done date" if there is no done date. The question mark (`?`) and `|| ""` are needed because the done date value may be null.
+- Like "group by task.done", except it uses an empty string instead of "No done date" if there is no done date.
 
 <!-- placeholder to force blank line after included text --> <!-- endInclude -->
 
@@ -184,34 +184,46 @@ These examples make heavy use of the [moment.js format characters](https://momen
 <!-- placeholder to force blank line before included text --> <!-- include: TaskPropertyExamples.test.custom_grouping_by_task.due_docs.approved.md -->
 
 ~~~text
-group by function task.due?.format("YYYY-MM-DD dddd") || ""
+group by function task.due.format("YYYY-MM-DD dddd")
 ~~~
 
-- Like "group by task.due", except it does not write "No due date" if there is no due date. The question mark (`?`) and `|| ""` are needed because the due date value may be null.
+- Like "group by task.due", except it uses an empty string instead of "No due date" if there is no due date.
 
 ~~~text
-group by function task.due?.format("dddd") || ""
+group by function task.due.formatAsDate()
+~~~
+
+- Format date as YYYY-MM-DD or empty string if no date.
+
+~~~text
+group by function task.due.formatAsDateAndTime()
+~~~
+
+- Format date as YYYY-MM-DD HH:mm or empty string if no date.
+
+~~~text
+group by function task.due.format("dddd")
 ~~~
 
 - Group by day of the week (Monday, Tuesday, etc).
 
 ~~~text
-group by function task.due?.format("YYYY MM MMM") || "no due date"
+group by function task.due.format("YYYY MM MMM", "no due date")
 ~~~
 
 - Group by month, for example "2023 05 May". The month number is also displayed, to control the sort order of headings.
 
 ~~~text
-group by function task.due?.format("YYYY-MM MMM [- Week] WW") || "no  date"
+group by function task.due.format("YYYY-MM MMM [- Week] WW", "no  date")
 ~~~
 
 - Group by month and week number, for example "2023-05 May - Week 22", or show a default heading if no date. If the month number is not displayed, in some years the first or last week of the year is displayed in a non-logical order.
 
 ~~~text
-group by function task.due?.fromNow() || ""
+group by function task.due.moment?.fromNow() || ""
 ~~~
 
-- Group by the time from now, for example "8 days ago". Whilst interesting, the alphabetical sort order makes the headings a little hard to read.
+- 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.
 
 <!-- placeholder to force blank line after included text --> <!-- endInclude -->
 
@@ -231,10 +243,10 @@ Since Tasks X.Y.Z, **custom grouping by scheduled date** is now possible.
 <!-- placeholder to force blank line before included text --> <!-- include: TaskPropertyExamples.test.custom_grouping_by_task.scheduled_docs.approved.md -->
 
 ~~~text
-group by function task.scheduled?.format("YYYY-MM-DD dddd") || ""
+group by function task.scheduled.format("YYYY-MM-DD dddd")
 ~~~
 
-- Like "group by task.scheduled", except it does not write "No scheduled date" if there is no scheduled date. The question mark (`?`) and `|| ""` are needed because the scheduled date value may be null.
+- Like "group by task.scheduled", except it uses an empty string instead of "No scheduled date" if there is no scheduled date.
 
 <!-- placeholder to force blank line after included text --> <!-- endInclude -->
 
@@ -256,10 +268,10 @@ Since Tasks X.Y.Z, **custom grouping by start date** is now possible.
 <!-- placeholder to force blank line before included text --> <!-- include: TaskPropertyExamples.test.custom_grouping_by_task.start_docs.approved.md -->
 
 ~~~text
-group by function task.start?.format("YYYY-MM-DD dddd") || ""
+group by function task.start.format("YYYY-MM-DD dddd")
 ~~~
 
-- Like "group by task.start", except it does not write "No start date" if there is no start date. The question mark (`?`) and `|| ""` are needed because the start date value may be null.
+- Like "group by task.start", except it uses an empty string instead of "No start date" if there is no start date.
 
 <!-- placeholder to force blank line after included text --> <!-- endInclude -->
 
@@ -304,10 +316,10 @@ Since Tasks X.Y.Z, **custom grouping by happens date** is now possible.
 <!-- placeholder to force blank line before included text --> <!-- include: TaskPropertyExamples.test.custom_grouping_by_task.happens_docs.approved.md -->
 
 ~~~text
-group by function task.happens?.format("YYYY-MM-DD dddd") || ""
+group by function task.happens.format("YYYY-MM-DD dddd")
 ~~~
 
-- Like "group by task.happens", except it does not write "No happens date" if none of task.start, task.scheduled, and task.due are set. The question mark (`?`) and `|| ""` are needed because the happens date value may be null.
+- Like "group by task.happens", except it uses an empty string instead of "No happens date" if there is no happens date.
 
 <!-- placeholder to force blank line after included text --> <!-- endInclude -->
 
diff --git a/docs/Scripting/Task Properties.md b/docs/Scripting/Task Properties.md
index 5cf5ece8..59a3e2c0 100644
--- a/docs/Scripting/Task Properties.md
+++ b/docs/Scripting/Task Properties.md
@@ -43,12 +43,12 @@ For more information, including adding your own customised statuses, see [[Statu
 
 | Field | Type 1 | Example 1 | Type 2 | Example 2 |
 | ----- | ----- | ----- | ----- | ----- |
-| `task.created` | `Moment` | `moment('2023-07-01 00:00')` | `null` | `null` |
-| `task.start` | `Moment` | `moment('2023-07-02 00:00')` | `null` | `null` |
-| `task.scheduled` | `Moment` | `moment('2023-07-03 00:00')` | `null` | `null` |
-| `task.due` | `Moment` | `moment('2023-07-04 00:00')` | `null` | `null` |
-| `task.done` | `Moment` | `moment('2023-07-05 00:00')` | `null` | `null` |
-| `task.happens` | `Moment` | `moment('2023-07-02 00:00')` | `null` | `null` |
+| `task.created` | `TasksDate` | `2023-07-01 00:00` | `TasksDate` | `` |
+| `task.start` | `TasksDate` | `2023-07-02 00:00` | `TasksDate` | `` |
+| `task.scheduled` | `TasksDate` | `2023-07-03 00:00` | `TasksDate` | `` |
+| `task.due` | `TasksDate` | `2023-07-04 00:00` | `TasksDate` | `` |
+| `task.done` | `TasksDate` | `2023-07-05 00:00` | `TasksDate` | `` |
+| `task.happens` | `TasksDate` | `2023-07-02 00:00` | `TasksDate` | `` |
 
 <!-- placeholder to force blank line after included text --> <!-- endInclude -->
 
diff --git a/src/Query/Filter/HappensDateField.ts b/src/Query/Filter/HappensDateField.ts
index caa020c6..bdbc6b25 100644
--- a/src/Query/Filter/HappensDateField.ts
+++ b/src/Query/Filter/HappensDateField.ts
@@ -48,7 +48,7 @@ export class HappensDateField extends DateField {
      * @param task
      */
     public earliestDate(task: Task): Moment | null {
-        return task.happens;
+        return task.happens.moment;
     }
 
     protected filterResultIfFieldMissing() {
diff --git a/src/Scripting/TasksDate.ts b/src/Scripting/TasksDate.ts
new file mode 100644
index 00000000..f641c301
--- /dev/null
+++ b/src/Scripting/TasksDate.ts
@@ -0,0 +1,57 @@
+import type { Moment } from 'moment';
+import { TaskRegularExpressions } from '../Task';
+
+/**
+ * TasksDate encapsulates a date, for simplifying the JavaScript expressions users need to
+ * write in 'group by function' lines.
+ */
+export class TasksDate {
+    private readonly _date: Moment | null = null;
+
+    public constructor(date: Moment | null) {
+        this._date = date;
+    }
+
+    /**
+     * Return the raw underlying moment (or null, if there is no date)
+     */
+    get moment(): Moment | null {
+        return this._date;
+    }
+
+    /**
+     * Return the date formatted as YYYY-MM-DD, or {@link fallBackText} if there is no date.
+     @param fallBackText - the string to use if the date is null. Defaults to empty string.
+     */
+    public formatAsDate(fallBackText: string = ''): string {
+        return this.format(TaskRegularExpressions.dateFormat, fallBackText);
+    }
+
+    /**
+     * Return the date formatted as YYYY-MM-DD HH:mm, or {@link fallBackText} if there is no date.
+     @param fallBackText - the string to use if the date is null. Defaults to empty string.
+     */
+    public formatAsDateAndTime(fallBackText: string = ''): string {
+        return this.format(TaskRegularExpressions.dateTimeFormat, fallBackText);
+    }
+
+    /**
+     * Return the date formatted with the given format string, or {@link fallBackText} if there is no date.
+     * See https://momentjs.com/docs/#/displaying/ for all the available formatting options.
+     * @param format
+     * @param fallBackText - the string to use if the date is null. Defaults to empty string.
+     */
+    public format(format: string, fallBackText: string = ''): string {
+        return this._date ? this._date!.format(format) : fallBackText;
+    }
+
+    /**
+     * Return the date as an ISO string, for example '2023-10-13T00:00:00.000Z'.
+     * @param keepOffset
+     * @returns - The date as an ISO string, for example: '2023-10-13T00:00:00.000Z',
+     *            OR an empty string if no date, OR null for an invalid date.
+     */
+    public toISOString(keepOffset?: boolean): string | null {
+        return this._date ? this._date!.toISOString(keepOffset) : '';
+    }
+}
diff --git a/src/Task.ts b/src/Task.ts
index d280af5a..f581701f 100644
--- a/src/Task.ts
+++ b/src/Task.ts
@@ -10,6 +10,7 @@ import { renderTaskLine } from './TaskLineRenderer';
 import type { TaskLineRenderDetails } from './TaskLineRenderer';
 import { DateFallback } from './DateFallback';
 import { compareByDate } from './lib/DateTools';
+import { TasksDate } from './Scripting/TasksDate';
 
 /**
  * When sorting, make sure low always comes after none. This way any tasks with low will be below any exiting
@@ -409,38 +410,38 @@ export class Task {
     }
 
     /**
-     * An alias for {@link createdDate}, so the field names in scripting docs are consistent with the existing search instruction names.
+     * Return {@link createdDate} as a {@link TasksDate}, so the field names in scripting docs are consistent with the existing search instruction names, and null values are easy to deal with.
      */
-    public get created(): Moment | null {
-        return this.createdDate;
+    public get created(): TasksDate {
+        return new TasksDate(this.createdDate);
     }
 
     /**
-     * An alias for {@link doneDate}, so the field names in scripting docs are consistent with the existing search instruction names.
+     * Return {@link doneDate} as a {@link TasksDate}, so the field names in scripting docs are consistent with the existing search instruction names, and null values are easy to deal with.
      */
-    public get done(): Moment | null {
-        return this.doneDate;
+    public get done(): TasksDate {
+        return new TasksDate(this.doneDate);
     }
 
     /**
-     * An alias for {@link dueDate}, so the field names in scripting docs are consistent with the existing search instruction names.
+     * Return {@link dueDate} as a {@link TasksDate}, so the field names in scripting docs are consistent with the existing search instruction names, and null values are easy to deal with.
      */
-    public get due(): Moment | null {
-        return this.dueDate;
+    public get due(): TasksDate {
+        return new TasksDate(this.dueDate);
     }
 
     /**
-     * An alias for {@link scheduledDate}, so the field names in scripting docs are consistent with the existing search instruction names.
+     * Return {@link scheduledDate} as a {@link TasksDate}, so the field names in scripting docs are consistent with the existing search instruction names, and null values are easy to deal with.
      */
-    public get scheduled(): Moment | null {
-        return this.scheduledDate;
+    public get scheduled(): TasksDate {
+        return new TasksDate(this.scheduledDate);
     }
 
     /**
-     * An alias for {@link startDate}, so the field names in scripting docs are consistent with the existing search instruction names.
+     * Return {@link startDate} as a {@link TasksDate}, so the field names in scripting docs are consistent with the existing search instruction names, and null values are easy to deal with.
      */
-    public get start(): Moment | null {
-        return this.startDate;
+    public get start(): TasksDate {
+        return new TasksDate(this.startDate);
     }
 
     /**
@@ -454,7 +455,7 @@ export class Task {
     }
 
     /**
-     * Return the earliest of the dates used by 'happens' in this given task, or null if none set.
+     * Return the earliest of the dates used by 'happens' in this given task as a {@link TasksDate}.
      *
      * 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.
@@ -462,10 +463,10 @@ export class Task {
      * @see happensDates
      * @see {@link HappensDateField}
      */
-    public get happens(): Moment | null {
+    public get happens(): TasksDate {
         const happensDates = this.happensDates;
         const sortedHappensDates = happensDates.sort(compareByDate);
-        return sortedHappensDates[0];
+        return new TasksDate(sortedHappensDates[0]);
     }
 
     /**


Test patch:
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.created_docs.approved.md b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.created_docs.approved.md
index 42b6fd1f..7ac3d8b0 100644
--- a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.created_docs.approved.md
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.created_docs.approved.md
@@ -2,10 +2,10 @@
 
 
 ~~~text
-group by function task.created?.format("YYYY-MM-DD dddd") || ""
+group by function task.created.format("YYYY-MM-DD dddd")
 ~~~
 
-- Like "group by task.created", except it does not write "No created date" if there is no created date. The question mark (`?`) and `|| ""` are needed because the created date value may be null.
+- Like "group by task.created", except it uses an empty string instead of "No created date" if there is no created date..
 
 
 
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.created_results.approved.txt b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.created_results.approved.txt
index 012e7115..ebbe9d4e 100644
--- a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.created_results.approved.txt
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.created_results.approved.txt
@@ -2,8 +2,8 @@ Results of custom groupers
 
 
 
-group by function task.created?.format("YYYY-MM-DD dddd") || ""
-Like "group by task.created", except it does not write "No created date" if there is no created date. The question mark (`?`) and `|| ""` are needed because the created date value may be null
+group by function task.created.format("YYYY-MM-DD dddd")
+Like "group by task.created", except it uses an empty string instead of "No created date" if there is no created date.
 =>
 2023-05-30 Tuesday
 2023-05-31 Wednesday
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.done_docs.approved.md b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.done_docs.approved.md
index 0a1cda75..ac5424bd 100644
--- a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.done_docs.approved.md
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.done_docs.approved.md
@@ -2,10 +2,10 @@
 
 
 ~~~text
-group by function task.done?.format("YYYY-MM-DD dddd") || ""
+group by function task.done.format("YYYY-MM-DD dddd")
 ~~~
 
-- Like "group by task.done", except it does not write "No done date" if there is no done date. The question mark (`?`) and `|| ""` are needed because the done date value may be null.
+- Like "group by task.done", except it uses an empty string instead of "No done date" if there is no done date.
 
 
 
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.done_results.approved.txt b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.done_results.approved.txt
index ad765b68..4bd2c4ba 100644
--- a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.done_results.approved.txt
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.done_results.approved.txt
@@ -2,8 +2,8 @@ Results of custom groupers
 
 
 
-group by function task.done?.format("YYYY-MM-DD dddd") || ""
-Like "group by task.done", except it does not write "No done date" if there is no done date. The question mark (`?`) and `|| ""` are needed because the done date value may be null
+group by function task.done.format("YYYY-MM-DD dddd")
+Like "group by task.done", except it uses an empty string instead of "No done date" if there is no done date
 =>
 2023-05-30 Tuesday
 2023-05-31 Wednesday
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.due_docs.approved.md b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.due_docs.approved.md
index 112948b5..7e1a0b71 100644
--- a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.due_docs.approved.md
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.due_docs.approved.md
@@ -2,38 +2,52 @@
 
 
 ~~~text
-group by function task.due?.format("YYYY-MM-DD dddd") || ""
+group by function task.due.format("YYYY-MM-DD dddd")
 ~~~
 
-- Like "group by task.due", except it does not write "No due date" if there is no due date. The question mark (`?`) and `|| ""` are needed because the due date value may be null.
+- Like "group by task.due", except it uses an empty string instead of "No due date" if there is no due date.
 
 
 ~~~text
-group by function task.due?.format("dddd") || ""
+group by function task.due.formatAsDate()
+~~~
+
+- Format date as YYYY-MM-DD or empty string if no date.
+
+
+~~~text
+group by function task.due.formatAsDateAndTime()
+~~~
+
+- Format date as YYYY-MM-DD HH:mm or empty string if no date.
+
+
+~~~text
+group by function task.due.format("dddd")
 ~~~
 
 - Group by day of the week (Monday, Tuesday, etc).
 
 
 ~~~text
-group by function task.due?.format("YYYY MM MMM") || "no due date"
+group by function task.due.format("YYYY MM MMM", "no due date")
 ~~~
 
 - Group by month, for example "2023 05 May". The month number is also displayed, to control the sort order of headings.
 
 
 ~~~text
-group by function task.due?.format("YYYY-MM MMM [- Week] WW") || "no  date"
+group by function task.due.format("YYYY-MM MMM [- Week] WW", "no  date")
 ~~~
 
 - Group by month and week number, for example "2023-05 May - Week 22", or show a default heading if no date. If the month number is not displayed, in some years the first or last week of the year is displayed in a non-logical order.
 
 
 ~~~text
-group by function task.due?.fromNow() || ""
+group by function task.due.moment?.fromNow() || ""
 ~~~
 
-- Group by the time from now, for example "8 days ago". Whilst interesting, the alphabetical sort order makes the headings a little hard to read.
+- 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.
 
 
 
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.due_results.approved.txt b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.due_results.approved.txt
index b312dc32..826bbc1a 100644
--- a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.due_results.approved.txt
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.due_results.approved.txt
@@ -2,8 +2,8 @@ Results of custom groupers
 
 
 
-group by function task.due?.format("YYYY-MM-DD dddd") || ""
-Like "group by task.due", except it does not write "No due date" if there is no due date. The question mark (`?`) and `|| ""` are needed because the due date value may be null
+group by function task.due.format("YYYY-MM-DD dddd")
+Like "group by task.due", except it uses an empty string instead of "No due date" if there is no due date
 =>
 2023-05-30 Tuesday
 2023-05-31 Wednesday
@@ -12,7 +12,27 @@ Invalid date
 ====================================================================================
 
 
-group by function task.due?.format("dddd") || ""
+group by function task.due.formatAsDate()
+Format date as YYYY-MM-DD or empty string if no date
+=>
+2023-05-30
+2023-05-31
+2023-06-01
+Invalid date
+====================================================================================
+
+
+group by function task.due.formatAsDateAndTime()
+Format date as YYYY-MM-DD HH:mm or empty string if no date
+=>
+2023-05-30 00:00
+2023-05-31 00:00
+2023-06-01 00:00
+Invalid date
+====================================================================================
+
+
+group by function task.due.format("dddd")
 Group by day of the week (Monday, Tuesday, etc)
 =>
 Invalid date
@@ -22,7 +42,7 @@ Wednesday
 ====================================================================================
 
 
-group by function task.due?.format("YYYY MM MMM") || "no due date"
+group by function task.due.format("YYYY MM MMM", "no due date")
 Group by month, for example "2023 05 May". The month number is also displayed, to control the sort order of headings
 =>
 2023 05 May
@@ -32,7 +52,7 @@ no due date
 ====================================================================================
 
 
-group by function task.due?.format("YYYY-MM MMM [- Week] WW") || "no  date"
+group by function task.due.format("YYYY-MM MMM [- Week] WW", "no  date")
 Group by month and week number, for example "2023-05 May - Week 22", or show a default heading if no date. If the month number is not displayed, in some years the first or last week of the year is displayed in a non-logical order
 =>
 2023-05 May - Week 22
@@ -42,8 +62,8 @@ no  date
 ====================================================================================
 
 
-group by function task.due?.fromNow() || ""
-Group by the time from now, for example "8 days ago". Whilst interesting, the alphabetical sort order makes the headings a little hard to read
+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
 =>
 10 days ago
 11 days ago
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.happens_docs.approved.md b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.happens_docs.approved.md
index 855d7dea..7ab741fb 100644
--- a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.happens_docs.approved.md
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.happens_docs.approved.md
@@ -2,10 +2,10 @@
 
 
 ~~~text
-group by function task.happens?.format("YYYY-MM-DD dddd") || ""
+group by function task.happens.format("YYYY-MM-DD dddd")
 ~~~
 
-- Like "group by task.happens", except it does not write "No happens date" if none of task.start, task.scheduled, and task.due are set. The question mark (`?`) and `|| ""` are needed because the happens date value may be null.
+- Like "group by task.happens", except it uses an empty string instead of "No happens date" if there is no happens date.
 
 
 
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.happens_results.approved.txt b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.happens_results.approved.txt
index 3c459753..c1140f9c 100644
--- a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.happens_results.approved.txt
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.happens_results.approved.txt
@@ -2,8 +2,8 @@ Results of custom groupers
 
 
 
-group by function task.happens?.format("YYYY-MM-DD dddd") || ""
-Like "group by task.happens", except it does not write "No happens date" if none of task.start, task.scheduled, and task.due are set. The question mark (`?`) and `|| ""` are needed because the happens date value may be null
+group by function task.happens.format("YYYY-MM-DD dddd")
+Like "group by task.happens", except it uses an empty string instead of "No happens date" if there is no happens date
 =>
 2023-05-30 Tuesday
 2023-05-31 Wednesday
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.scheduled_docs.approved.md b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.scheduled_docs.approved.md
index 13c714ca..261162c1 100644
--- a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.scheduled_docs.approved.md
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.scheduled_docs.approved.md
@@ -2,10 +2,10 @@
 
 
 ~~~text
-group by function task.scheduled?.format("YYYY-MM-DD dddd") || ""
+group by function task.scheduled.format("YYYY-MM-DD dddd")
 ~~~
 
-- Like "group by task.scheduled", except it does not write "No scheduled date" if there is no scheduled date. The question mark (`?`) and `|| ""` are needed because the scheduled date value may be null.
+- Like "group by task.scheduled", except it uses an empty string instead of "No scheduled date" if there is no scheduled date.
 
 
 
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.scheduled_results.approved.txt b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.scheduled_results.approved.txt
index 07430dc1..e3b3b747 100644
--- a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.scheduled_results.approved.txt
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.scheduled_results.approved.txt
@@ -2,8 +2,8 @@ Results of custom groupers
 
 
 
-group by function task.scheduled?.format("YYYY-MM-DD dddd") || ""
-Like "group by task.scheduled", except it does not write "No scheduled date" if there is no scheduled date. The question mark (`?`) and `|| ""` are needed because the scheduled date value may be null
+group by function task.scheduled.format("YYYY-MM-DD dddd")
+Like "group by task.scheduled", except it uses an empty string instead of "No scheduled date" if there is no scheduled date
 =>
 2023-05-30 Tuesday
 2023-05-31 Wednesday
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.start_docs.approved.md b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.start_docs.approved.md
index 815eb118..1cec4130 100644
--- a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.start_docs.approved.md
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.start_docs.approved.md
@@ -2,10 +2,10 @@
 
 
 ~~~text
-group by function task.start?.format("YYYY-MM-DD dddd") || ""
+group by function task.start.format("YYYY-MM-DD dddd")
 ~~~
 
-- Like "group by task.start", except it does not write "No start date" if there is no start date. The question mark (`?`) and `|| ""` are needed because the start date value may be null.
+- Like "group by task.start", except it uses an empty string instead of "No start date" if there is no start date.
 
 
 
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.start_results.approved.txt b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.start_results.approved.txt
index 952b0067..7fcb2f79 100644
--- a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.start_results.approved.txt
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.start_results.approved.txt
@@ -2,8 +2,8 @@ Results of custom groupers
 
 
 
-group by function task.start?.format("YYYY-MM-DD dddd") || ""
-Like "group by task.start", except it does not write "No start date" if there is no start date. The question mark (`?`) and `|| ""` are needed because the start date value may be null
+group by function task.start.format("YYYY-MM-DD dddd")
+Like "group by task.start", except it uses an empty string instead of "No start date" if there is no start date
 =>
 2023-05-30 Tuesday
 2023-05-31 Wednesday
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.ts b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.ts
index 684e3816..f5f59f89 100644
--- a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.ts
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.ts
@@ -33,8 +33,8 @@ describe('custom grouping by', () => {
             'task.created',
             [
                 [
-                    'group by function task.created?.format("YYYY-MM-DD dddd") || ""',
-                    'Like "group by task.created", except it does not write "No created date" if there is no created date. The question mark (`?`) and `|| ""` are needed because the created date value may be null',
+                    'group by function task.created.format("YYYY-MM-DD dddd")',
+                    'Like "group by task.created", except it uses an empty string instead of "No created date" if there is no created date.',
                 ],
             ],
             SampleTasks.withAllRepresentativeCreatedDates(),
@@ -44,8 +44,8 @@ describe('custom grouping by', () => {
             'task.done',
             [
                 [
-                    'group by function task.done?.format("YYYY-MM-DD dddd") || ""',
-                    'Like "group by task.done", except it does not write "No done date" if there is no done date. The question mark (`?`) and `|| ""` are needed because the done date value may be null',
+                    'group by function task.done.format("YYYY-MM-DD dddd")',
+                    'Like "group by task.done", except it uses an empty string instead of "No done date" if there is no done date',
                 ],
             ],
             SampleTasks.withAllRepresentativeDoneDates(),
@@ -55,21 +55,26 @@ describe('custom grouping by', () => {
             'task.due',
             [
                 [
-                    'group by function task.due?.format("YYYY-MM-DD dddd") || ""',
-                    'Like "group by task.due", except it does not write "No due date" if there is no due date. The question mark (`?`) and `|| ""` are needed because the due date value may be null',
+                    'group by function task.due.format("YYYY-MM-DD dddd")',
+                    'Like "group by task.due", except it uses an empty string instead of "No due date" if there is no due date',
                 ],
-                ['group by function task.due?.format("dddd") || ""', 'Group by day of the week (Monday, Tuesday, etc)'],
+                ['group by function task.due.formatAsDate()', 'Format date as YYYY-MM-DD or empty string if no date'],
                 [
-                    'group by function task.due?.format("YYYY MM MMM") || "no due date"',
+                    'group by function task.due.formatAsDateAndTime()',
+                    'Format date as YYYY-MM-DD HH:mm or empty string if no date',
+                ],
+                ['group by function task.due.format("dddd")', 'Group by day of the week (Monday, Tuesday, etc)'],
+                [
+                    'group by function task.due.format("YYYY MM MMM", "no due date")',
                     'Group by month, for example "2023 05 May". The month number is also displayed, to control the sort order of headings',
                 ],
                 [
-                    'group by function task.due?.format("YYYY-MM MMM [- Week] WW") || "no  date"',
+                    'group by function task.due.format("YYYY-MM MMM [- Week] WW", "no  date")',
                     'Group by month and week number, for example "2023-05 May - Week 22", or show a default heading if no date. If the month number is not displayed, in some years the first or last week of the year is displayed in a non-logical order',
                 ],
                 [
-                    'group by function task.due?.fromNow() || ""',
-                    'Group by the time from now, for example "8 days ago". Whilst interesting, the alphabetical sort order makes the headings a little hard to read',
+                    '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',
                 ],
             ],
             SampleTasks.withAllRepresentativeDueDates(),
@@ -79,8 +84,8 @@ describe('custom grouping by', () => {
             'task.happens',
             [
                 [
-                    'group by function task.happens?.format("YYYY-MM-DD dddd") || ""',
-                    'Like "group by task.happens", except it does not write "No happens date" if none of task.start, task.scheduled, and task.due are set. The question mark (`?`) and `|| ""` are needed because the happens date value may be null',
+                    'group by function task.happens.format("YYYY-MM-DD dddd")',
+                    'Like "group by task.happens", except it uses an empty string instead of "No happens date" if there is no happens date',
                 ],
             ],
             SampleTasks.withAllRepresentativeDueDates(),
@@ -90,8 +95,8 @@ describe('custom grouping by', () => {
             'task.scheduled',
             [
                 [
-                    'group by function task.scheduled?.format("YYYY-MM-DD dddd") || ""',
-                    'Like "group by task.scheduled", except it does not write "No scheduled date" if there is no scheduled date. The question mark (`?`) and `|| ""` are needed because the scheduled date value may be null',
+                    'group by function task.scheduled.format("YYYY-MM-DD dddd")',
+                    'Like "group by task.scheduled", except it uses an empty string instead of "No scheduled date" if there is no scheduled date',
                 ],
             ],
             SampleTasks.withAllRepresentativeScheduledDates(),
@@ -101,8 +106,8 @@ describe('custom grouping by', () => {
             'task.start',
             [
                 [
-                    'group by function task.start?.format("YYYY-MM-DD dddd") || ""',
-                    'Like "group by task.start", except it does not write "No start date" if there is no start date. The question mark (`?`) and `|| ""` are needed because the start date value may be null',
+                    'group by function task.start.format("YYYY-MM-DD dddd")',
+                    'Like "group by task.start", except it uses an empty string instead of "No start date" if there is no start date',
                 ],
             ],
             SampleTasks.withAllRepresentativeStartDates(),
diff --git a/tests/Scripting/ScriptingTestHelpers.ts b/tests/Scripting/ScriptingTestHelpers.ts
index c1b6a1e4..cb96faa5 100644
--- a/tests/Scripting/ScriptingTestHelpers.ts
+++ b/tests/Scripting/ScriptingTestHelpers.ts
@@ -1,5 +1,6 @@
 import moment from 'moment';
 import { TaskRegularExpressions } from '../../src/Task';
+import { TasksDate } from '../../src/Scripting/TasksDate';
 
 export function formatToRepresentType(x: any): string {
     if (typeof x === 'string') {
@@ -10,6 +11,10 @@ export function formatToRepresentType(x: any): string {
         return `moment('${x.format(TaskRegularExpressions.dateTimeFormat)}')`;
     }
 
+    if (x instanceof TasksDate) {
+        return x.formatAsDateAndTime();
+    }
+
     if (Array.isArray(x)) {
         return '[' + x.map((v) => formatToRepresentType(v)).join(', ') + ']';
     }
@@ -32,6 +37,10 @@ export function determineExpressionType(value: any) {
         return 'Moment';
     }
 
+    if (value instanceof TasksDate) {
+        return 'TasksDate';
+    }
+
     if (Array.isArray(value)) {
         if (value.length > 0) {
             return `${typeof value[0]}[]`;
diff --git a/tests/Scripting/TaskProperties.test.task_dates.approved.md b/tests/Scripting/TaskProperties.test.task_dates.approved.md
index f28b5ff5..be634e21 100644
--- a/tests/Scripting/TaskProperties.test.task_dates.approved.md
+++ b/tests/Scripting/TaskProperties.test.task_dates.approved.md
@@ -2,12 +2,12 @@
 
 | Field | Type 1 | Example 1 | Type 2 | Example 2 |
 | ----- | ----- | ----- | ----- | ----- |
-| `task.created` | `Moment` | `moment('2023-07-01 00:00')` | `null` | `null` |
-| `task.start` | `Moment` | `moment('2023-07-02 00:00')` | `null` | `null` |
-| `task.scheduled` | `Moment` | `moment('2023-07-03 00:00')` | `null` | `null` |
-| `task.due` | `Moment` | `moment('2023-07-04 00:00')` | `null` | `null` |
-| `task.done` | `Moment` | `moment('2023-07-05 00:00')` | `null` | `null` |
-| `task.happens` | `Moment` | `moment('2023-07-02 00:00')` | `null` | `null` |
+| `task.created` | `TasksDate` | `2023-07-01 00:00` | `TasksDate` | `` |
+| `task.start` | `TasksDate` | `2023-07-02 00:00` | `TasksDate` | `` |
+| `task.scheduled` | `TasksDate` | `2023-07-03 00:00` | `TasksDate` | `` |
+| `task.due` | `TasksDate` | `2023-07-04 00:00` | `TasksDate` | `` |
+| `task.done` | `TasksDate` | `2023-07-05 00:00` | `TasksDate` | `` |
+| `task.happens` | `TasksDate` | `2023-07-02 00:00` | `TasksDate` | `` |
 
 
 <!-- placeholder to force blank line after included text -->
diff --git a/tests/Scripting/TasksDate.test.ts b/tests/Scripting/TasksDate.test.ts
new file mode 100644
index 00000000..99e5e16d
--- /dev/null
+++ b/tests/Scripting/TasksDate.test.ts
@@ -0,0 +1,53 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import moment from 'moment';
+
+import { TasksDate } from '../../src/Scripting/TasksDate';
+
+window.moment = moment;
+
+beforeEach(() => {
+    jest.useFakeTimers();
+    jest.setSystemTime(new Date('2023-06-11 20:00'));
+});
+
+afterEach(() => {
+    jest.useRealTimers();
+});
+
+describe('TasksDate', () => {
+    it('should format valid dates', () => {
+        const date = '2023-10-13';
+        const tasksDate = new TasksDate(moment(date));
+        expect(tasksDate.format('dddd')).toEqual('Friday');
+        expect(tasksDate.formatAsDate()).toEqual(date);
+        expect(tasksDate.formatAsDateAndTime()).toEqual(date + ' 00:00');
+        expect(tasksDate.toISOString()).toEqual('2023-10-13T00:00:00.000Z');
+    });
+
+    it('should format null dates as empty string', () => {
+        const tasksDate = new TasksDate(null);
+        expect(tasksDate.format('dddd')).toEqual('');
+        expect(tasksDate.formatAsDate()).toEqual('');
+        expect(tasksDate.formatAsDateAndTime()).toEqual('');
+        expect(tasksDate.toISOString()).toEqual('');
+    });
+
+    it('should format null dates as provided default string', () => {
+        const tasksDate = new TasksDate(null);
+        const fallBackText = 'no date';
+        expect(tasksDate.format('dddd', fallBackText)).toEqual(fallBackText);
+        expect(tasksDate.formatAsDate(fallBackText)).toEqual(fallBackText);
+        expect(tasksDate.formatAsDateAndTime(fallBackText)).toEqual(fallBackText);
+    });
+
+    it('should format invalid dates meaningfully', () => {
+        const tasksDate = new TasksDate(moment('2023-12-32'));
+        expect(tasksDate.format('dddd')).toEqual('Invalid date');
+        expect(tasksDate.formatAsDate()).toEqual('Invalid date');
+        expect(tasksDate.formatAsDateAndTime()).toEqual('Invalid date');
+        expect(tasksDate.toISOString()).toBeNull();
+    });
+});
diff --git a/tests/Task.test.ts b/tests/Task.test.ts
index ea13f73f..2e6e9e19 100644
--- a/tests/Task.test.ts
+++ b/tests/Task.test.ts
@@ -413,11 +413,11 @@ describe('parsing tags', () => {
 describe('properties for scripting', () => {
     it('should provide access to all date fields', () => {
         const task = TaskBuilder.createFullyPopulatedTask();
-        expect(task.created!).toEqualMoment(task.createdDate!);
-        expect(task.done!).toEqualMoment(task.doneDate!);
-        expect(task.due!).toEqualMoment(task.dueDate!);
-        expect(task.scheduled!).toEqualMoment(task.scheduledDate!);
-        expect(task.start!).toEqualMoment(task.startDate!);
+        expect(task.created.moment!).toEqualMoment(task.createdDate!);
+        expect(task.done.moment!).toEqualMoment(task.doneDate!);
+        expect(task.due.moment!).toEqualMoment(task.dueDate!);
+        expect(task.scheduled!.moment).toEqualMoment(task.scheduledDate!);
+        expect(task.start.moment!).toEqualMoment(task.startDate!);
     });
 
     it('should provide access to happens and happensDates', () => {
@@ -429,14 +429,14 @@ describe('properties for scripting', () => {
         expect(task.happensDates[0]).toEqualMoment(moment(startDate));
         expect(task.happensDates[1]).toEqualMoment(moment(scheduledDate));
         expect(task.happensDates[2]).toEqualMoment(moment(dueDate));
-        expect(task.happens!).toEqualMoment(moment(dueDate)!); // the earliest
+        expect(task.happens.moment).toEqualMoment(moment(dueDate)!); // the earliest
     });
 
     it('happens should ignore non-contributing date fields', () => {
         // other fields, that are ignored by happens:
         const sampleDate = '2023-06-19';
-        expect(new TaskBuilder().createdDate(sampleDate).build().happens).toBeNull();
-        expect(new TaskBuilder().doneDate(sampleDate).build().happens).toBeNull();
+        expect(new TaskBuilder().createdDate(sampleDate).build().happens.moment).toBeNull();
+        expect(new TaskBuilder().doneDate(sampleDate).build().happens.moment).toBeNull();
     });
 
     it('should provide access to recurring-related properties', () => {