Repository: obsidian-tasks-group/obsidian-tasks
Pull request #2028 (2023-06-10 20:52:17)

feat: Add custom grouping, with 'group by function' option
<!--- Provide a general summary of your changes in the Title above -->

# Description

<!--- Describe your changes in detail -->

A powerful, flexible mechanism to give users a lot of control over custom grouping rules.

**Credit**: This work was only made possible by @hauntedhost, who kindly taught me how to evaluate user-supplied functions in TypeScript.

## Examples (taken from the docs)

~~~text  
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.  
  
~~~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 month, for example "2023 05 May". The month number is also displayed, to control the sort order of headings.

~~~text  
group by function task.description.replace('short', '==short==')  
~~~  
  
- Highlight the word "short" in any group descriptions.

~~~text  
group by function task.tags.filter( (t) => t.includes("#context/"))  
~~~  
  
- Only create headings for tags that contain "#context/".  
  
~~~text  
group by function task.tags.filter( (t) => ! t.includes("#tag"))  
~~~  
  
- Create headings for all tags that do not contain "#tag".

## New Features

- Add major new `group by function` feature, for custom grouping by many of the properties of tasks
- This adds the ability to group by some fields that it was not previously possible to group by:
    - `task.description`
    - `task.status.symbol`
    - `task.status.nextStatusSymbol`
- It enables `custom-formatting of date headings`, when grouping by date fields
    - So for example, grouping by week or month.
    - See the `group by due` docs for examples

## New Documentation

- New top-level section `Scripting`
- **Note the documentation will be finalised in later commits** - I need a break from this now and just want to merge it, so I can pick up later improvements in smaller chunks of work.

## Enabling changes

- Add some getters to `Task` whose names match the corresponding query instructions:
    - `created`
    - `done`
    - `due`
    - `scheduled`
    - `start`
    - `happens` (and also `happensDates`)
    - `recurring` (for future use)

## TODO

Not all fields are exposed yet, as some will require refactoring to avoid them in a way that is consistent with the current filter instructions.

Notable omissions:

- [ ] `task.priority`
- [ ] `task.file.path`
- [ ] `task.file.fileName`
- [ ] `task.file.root`
- [ ] `task.file.folder`
- [ ] `task.heading`

---

## 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. -->

There have been quite a few requests for variations on existing grouping options. This enables many of those to be done in one single step.

## 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. -->

- Lots of manual testing in Obsidian.
- Lots of tests added.
- All the documentation examples are machined-generated, so if the sample `group by function` instructions ever break, we will know.

## 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**)
- [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] **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/Introduction.md b/docs/Introduction.md
index 3e0be65b..be0d438e 100644
--- a/docs/Introduction.md
+++ b/docs/Introduction.md
@@ -32,6 +32,7 @@ These are the main pages and sections, that connect to all the pages here:
 >
 > ### Advanced and Detail
 >
+> - [[About Scripting|Scripting ...]]
 > - [[About Advanced|Advanced ...]]
 > - [[About Other Plugins|Other Plugins ...]]
 > - [[About Reference|Reference ...]]
diff --git a/docs/Queries/Grouping.md b/docs/Queries/Grouping.md
index 4224d3bb..c52d12ed 100644
--- a/docs/Queries/Grouping.md
+++ b/docs/Queries/Grouping.md
@@ -4,6 +4,8 @@ publish: true
 
 # Grouping
 
+<span class="related-pages">#feature/grouping</span>
+
 ---
 
 ## Contents
@@ -15,6 +17,7 @@ This page is long. Here are some links to the main sections:
 - [[#Group by Dates in Tasks]]
 - [[#Group by Other Task Properties]]
 - [[#Group by File Properties]]
+- [[#Group by Function - Custom Groups]]
 - [[#Multiple groups]]
 - [[#Refining groups]]
 - [[#Notes]]
@@ -52,6 +55,26 @@ For more information, including adding your own customised statuses, see [[Statu
 > [!released]
 `group by status.name` was introduced in Tasks 1.23.0.
 
+Since Tasks X.Y.Z, **custom grouping by status names** is now possible.
+
+<!-- placeholder to force blank line before included text --> <!-- include: TaskPropertyExamples.test.custom_grouping_by_task.status.name_docs.approved.md -->
+
+~~~text
+group by function task.status.name
+~~~
+
+- Identical to "group by status.name".
+
+~~~text
+group by function task.status.name.toUpperCase()
+~~~
+
+- Convert the status names to capitals.
+
+<!-- placeholder to force blank line after included text --> <!-- endInclude -->
+
+For more information, see [[Custom Grouping]].
+
 ### Status Type
 
 - `group by status.type`
@@ -66,6 +89,56 @@ For more information, including adding your own customised statuses, see [[Statu
 > [!released]
 `group by status.type` was introduced in Tasks 1.23.0.
 
+Since Tasks X.Y.Z, **custom grouping by status types** is now possible.
+
+<!-- placeholder to force blank line before included text --> <!-- include: TaskPropertyExamples.test.custom_grouping_by_task.status.type_docs.approved.md -->
+
+~~~text
+group by function task.status.type
+~~~
+
+- Unlike "group by status.type", this sorts the status types in alphabetical order.
+
+<!-- placeholder to force blank line after included text --> <!-- endInclude -->
+
+For more information, see [[Custom Grouping]].
+
+### Status Symbol
+
+There is no built-in instruction to group by status symbols.
+
+Since Tasks X.Y.Z, **custom grouping by status symbol** is now possible.
+
+<!-- placeholder to force blank line before included text --> <!-- include: TaskPropertyExamples.test.custom_grouping_by_task.status.symbol_docs.approved.md -->
+
+~~~text
+group by function "Status symbol: " + task.status.symbol.replace(" ", "space")
+~~~
+
+- Group by the status symbol, making space characters visible.
+
+<!-- placeholder to force blank line after included text --> <!-- endInclude -->
+
+For more information, see [[Custom Grouping]].
+
+### Next Status Symbol
+
+There is no built-in instruction to group by next status symbols.
+
+Since Tasks X.Y.Z, **custom grouping by next status symbol** is now possible.
+
+<!-- placeholder to force blank line before included text --> <!-- include: TaskPropertyExamples.test.custom_grouping_by_task.status.nextStatusSymbol_docs.approved.md -->
+
+~~~text
+group by function "Next status symbol: " + task.status.nextStatusSymbol.replace(" ", "space")
+~~~
+
+- Group by the next status symbol, making space characters visible.
+
+<!-- placeholder to force blank line after included text --> <!-- endInclude -->
+
+For more information, see [[Custom Grouping]].
+
 ---
 
 ## Group by Dates in Tasks
@@ -79,6 +152,22 @@ For more information, including adding your own customised statuses, see [[Statu
 >
 > - `done` grouping option was introduced in Tasks 1.7.0.
 
+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") || ""
+~~~
+
+- 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.
+
+<!-- placeholder to force blank line after included text --> <!-- endInclude -->
+
+For more examples, see [[#Due Date]].
+
+For more information, see [[Custom Grouping]].
+
 ### Due Date
 
 - `group by due`
@@ -88,6 +177,46 @@ For more information, including adding your own customised statuses, see [[Statu
 >
 > - `due` grouping option was introduced in Tasks 1.7.0.
 
+Since Tasks X.Y.Z, **custom grouping by due date** is now possible.
+
+These examples make heavy use of the [moment.js format characters](https://momentjs.com/docs/#/displaying/format/).
+
+<!-- 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") || ""
+~~~
+
+- 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.
+
+~~~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 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 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 the time from now, for example "8 days ago". Whilst interesting, the alphabetical sort order makes the headings a little hard to read.
+
+<!-- placeholder to force blank line after included text --> <!-- endInclude -->
+
+For more information, see [[Custom Grouping]].
+
 ### Scheduled Date
 
 - `group by scheduled`
@@ -97,6 +226,22 @@ For more information, including adding your own customised statuses, see [[Statu
 >
 > - `scheduled` grouping option was introduced in Tasks 1.7.0.
 
+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") || ""
+~~~
+
+- 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.
+
+<!-- placeholder to force blank line after included text --> <!-- endInclude -->
+
+For more examples, see [[#Due Date]].
+
+For more information, see [[Custom Grouping]].
+
 ### Start Date
 
 - `group by start`
@@ -106,6 +251,22 @@ For more information, including adding your own customised statuses, see [[Statu
 >
 > - `start` grouping option was introduced in Tasks 1.7.0.
 
+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") || ""
+~~~
+
+- 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.
+
+<!-- placeholder to force blank line after included text --> <!-- endInclude -->
+
+For more examples, see [[#Due Date]].
+
+For more information, see [[Custom Grouping]].
+
 ### Created Date
 
 - `group by created`
@@ -114,6 +275,22 @@ For more information, including adding your own customised statuses, see [[Statu
 > [!released]
 `created` grouping option was introduced in Tasks 2.0.0.
 
+Since Tasks X.Y.Z, **custom grouping by created date** is now possible.
+
+<!-- placeholder to force blank line before included text --> <!-- TaskPropertyExamples.test.custom_grouping_by_task.created_docs.approved.md -->
+
+~~~text
+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.
+
+<!-- placeholder to force blank line after included text --> <!-- endInclude -->
+
+For more examples, see [[#Due Date]].
+
+For more information, see [[Custom Grouping]].
+
 ### Happens
 
 - `group by happens`
@@ -122,10 +299,62 @@ For more information, including adding your own customised statuses, see [[Statu
 > [!released]
 `happens` grouping option was introduced in Tasks 1.11.0.
 
+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") || ""
+~~~
+
+- 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.
+
+<!-- placeholder to force blank line after included text --> <!-- endInclude -->
+
+For more examples, see [[#Due Date]].
+
+For more information, see [[Custom Grouping]].
+
 ---
 
 ## Group by Other Task Properties
 
+As well as the date-related groups above, groups can be created from properties in individual tasks.
+
+### Description
+
+Since Tasks X.Y.Z, **custom grouping by description** is now possible.
+
+<!-- placeholder to force blank line before included text --> <!-- include: TaskPropertyExamples.test.custom_grouping_by_task.description_docs.approved.md -->
+
+~~~text
+group by function task.description
+~~~
+
+- group by description. This might be useful for finding completed recurrences of the same task.
+
+~~~text
+group by function task.description.toUpperCase()
+~~~
+
+- Convert the description to capitals.
+
+~~~text
+group by function task.description.slice(0, 25)
+~~~
+
+- Truncate descriptions to at most their first 25 characters, and group by that string.
+
+~~~text
+group by function task.description.replace('short', '==short==')
+~~~
+
+- Highlight the word "short" in any group descriptions.
+
+<!-- placeholder to force blank line after included text --> <!-- endInclude -->
+
+For more information, see [[Custom Grouping]].
+
 ### Priority
 
 - `group by priority`
@@ -141,6 +370,9 @@ For more information, including adding your own customised statuses, see [[Statu
 >
 > - `priority` grouping option was introduced in Tasks 1.11.0.
 
+> [!Note]
+> `group by function` is not yet able to refer task priorities.
+
 ### Urgency
 
 - `group by urgency` ([[Urgency|urgency]])
@@ -152,6 +384,20 @@ For more information, including adding your own customised statuses, see [[Statu
 >
 > - `urgency` grouping option was introduced in Tasks 3.6.0.
 
+Since Tasks X.Y.Z, **custom grouping by urgency** is now possible.
+
+<!-- placeholder to force blank line before included text --> <!-- include: TaskPropertyExamples.test.custom_grouping_by_task.urgency_docs.approved.md -->
+
+~~~text
+group by function task.urgency.toFixed(3)
+~~~
+
+- Show the urgency to 3 decimal places, unlike the built-in "group by urgency" which uses 2.
+
+<!-- placeholder to force blank line after included text --> <!-- endInclude -->
+
+For more information, see [[Custom Grouping]].
+
 ### Recurrence
 
 - `group by recurring`
@@ -173,6 +419,38 @@ For more information, including adding your own customised statuses, see [[Statu
 >
 > - `tags` grouping option was introduced in Tasks 1.10.0.
 
+Since Tasks X.Y.Z, **custom grouping by tags** is now possible.
+
+<!-- placeholder to force blank line before included text --> <!-- include: TaskPropertyExamples.test.custom_grouping_by_task.tags_docs.approved.md -->
+
+~~~text
+group by function task.tags
+~~~
+
+- Like "group by tags" except that tasks with no tags have no heading instead of "(No tags)".
+
+~~~text
+group by function task.tags.join(", ")
+~~~
+
+- Tasks with multiple tags are listed once, with a heading that combines all the tags. Separating with commas means the tags are clickable in the headings.
+
+~~~text
+group by function task.tags.filter( (t) => t.includes("#context/"))
+~~~
+
+- Only create headings for tags that contain "#context/".
+
+~~~text
+group by function task.tags.filter( (t) => ! t.includes("#tag"))
+~~~
+
+- Create headings for all tags that do not contain "#tag".
+
+<!-- placeholder to force blank line after included text --> <!-- endInclude -->
+
+For more information, see [[Custom Grouping]].
+
 ---
 
 ## Group by File Properties
@@ -207,6 +485,19 @@ For more information, including adding your own customised statuses, see [[Statu
 
 ---
 
+## Group by Function - Custom Groups
+
+Tasks has a powerful mechanism for you to create your own custom groups, offering incredible flexibility.
+
+> [!released]
+> `group by function` was introduced in Tasks X.Y.Z.
+
+==TODO Provide examples==
+
+For details, see [[Custom Grouping]].
+
+---
+
 ## Multiple groups
 
 You can add multiple `group by` query options, each on an extra line.
diff --git a/docs/Scripting/About Scripting.md b/docs/Scripting/About Scripting.md
new file mode 100644
index 00000000..35750bf5
--- /dev/null
+++ b/docs/Scripting/About Scripting.md
@@ -0,0 +1,20 @@
+---
+publish: true
+---
+
+# About Scripting
+
+<span class="related-pages">#index-pages #feature/scripting</span>
+
+## Contents
+
+This is the start of a growing set of documentation on how to get finer control of searches within Tasks.
+
+### Basics
+
+- [[Task Properties]]
+- [[Expressions]]
+
+### Scripting capabilities
+
+- [[Custom Grouping]] - write short JavaScript expressions to create task group names in Tasks query results.
diff --git a/docs/Scripting/Custom Grouping.md b/docs/Scripting/Custom Grouping.md
new file mode 100644
index 00000000..353acf19
--- /dev/null
+++ b/docs/Scripting/Custom Grouping.md
@@ -0,0 +1,136 @@
+---
+publish: true
+---
+
+# Custom Grouping
+
+<span class="related-pages">#feature/scripting #feature/grouping</span>
+
+> [!released]
+> Custom grouping was introduced in Tasks X.Y.Z.
+
+## Basics of custom grouping
+
+- A task, whose data you can access via all the [[Task Properties]]
+- an instruction line of the form:
+  - `group by function <expression>`
+  - See [[Expressions]]
+  - that returns one of:
+    - a single value (the group name)
+      - where an empty string means 'no group'
+    - an array of values
+      - where an empty array means 'no group'
+      - and multiple values in the array mean the task is listed multiple times
+- Groups are sorted in alphabetical order by their name.
+- You can insert marking characters to format the headings
+
+## Worked Example
+
+> [!Example]
+> Suppose that we want to see which of our tasks have the longest descriptions.
+>
+> To do this, we could group tasks with group headings like:
+>
+> `Description length: 27` - for all tasks with description 27 characters long.
+>
+> We can achieve this with this line, where the `reverse` work makes the tasks with longest descriptions be shown first:
+>
+> `group by function reverse 'Description length: ' + task.description.length`
+
+Here is a visual explanation of how that instruction behaves:
+
+```mermaid
+flowchart TB
+
+4[Tasks code block]
+5["group by function reverse 'Description length: ' + task.description.length"]
+4 --> 5
+
+8["- [ ] #task Hello"]
+9["task.description
+task.indentation
+task.urgency
+etc"]
+10["'Description length: ' + task.description.length"]
+12["'Description length: ' + '#task Hello'.length"]
+13['Description length: 11']
+
+5 --> 10
+8 --> 9 --> 10 --> 12 --> 13
+```
+
+## Expressions
+
+The instructions look like this:
+
+- `group by function <expression>`
+- `group by function reverse <expression>`
+
+The `expression` can:
+
+- use a large range of properties of each task
+- use any valid JavaScript language features
+
+The `expression` must:
+
+- use properties on a given task, such as `task.description`, `task.status.name`
+  - See the reference page [[Task Properties]] for all the available properties
+- return one of:
+  - either a single value of any type that can be converted to string
+  - or an array of values (in which case, the task will be displayed multiple times, once under each heading generated from the array)
+
+> [!warning]
+> The strings returned are rendered as-is. This means, for example, that if the text you return has underscores in (`_`) that are not meant to indicate italics, you should escape them with backslashes ('\_') like this:
+>
+> ```text
+> group by function task.description.replaceAll('_', '\\_')
+>```
+
+## Custom group examples
+
+```text
+group by function task.priority
+group by function task.status.nextStatusSymbol.replace(" ", "space")
+group by function task.status.symbol.replace(" ", "space")
+```
+
+<!--
+Using task.path, so not yet reading for public visibility:
+```text
+group by function task.path.replace("some/prefix/", "")
+group by function reverse task.path.startsWith("journal/") ? "journal/" : task.path
+group by function task.path.startsWith("journal/") ? "journal/" : task.path
+```
+-->
+
+## Available Properties
+
+The Reference section has a complete list of available [[Task Properties]].
+
+## Troubleshooting
+
+> [!Warning]
+> Currently most types of error in function expressions are only detected when the search runs.
+>
+> This means that error messages are displayed in the group headings, when results are viewed.
+>
+> In a future release, we plan to show errors in formulae when the query block is being read.
+
+### Syntax error
+
+The following example gives an error:
+
+````text
+```tasks
+group by function hello
+```
+````
+
+gives this heading name:
+
+```text
+##### Error: Failed calculating expression "hello". The error message was: hello is not defined
+```
+
+> [!todo]
+> Do syntax-error checking when parsing the instruction
diff --git a/docs/Scripting/Expressions.md b/docs/Scripting/Expressions.md
new file mode 100644
index 00000000..9a15004f
--- /dev/null
+++ b/docs/Scripting/Expressions.md
@@ -0,0 +1,69 @@
+---
+publish: true
+---
+
+# Expressions
+
+<span class="related-pages">#feature/scripting</span>
+
+> [!released]
+> Expressions were introduced in Tasks X.Y.Z.
+
+## Introduction
+
+> [!Note]
+> Writing a full explanation of the JavaScript language is outside the scope of this documentation.
+>
+> We provide some introductory material and some well-thought-out examples to give you ideas, and encourage you to experiment and try the feature out for yourself.
+
+## What are Expressions?
+
+- Language is JavaScript
+- The expression is a string instruction
+- Depending on the context, one or two tasks are passed in to the expression, and a calculation is performed.
+- Calculate some new value based on the input
+
+## Example expressions
+
+The real value of expressions is to calculate values from data in tasks.
+
+In this section, we use artificially simple fixed expressions, to demonstrate the kinds of abilities available.
+
+Each line below is of the form:
+
+~~~text
+expression => result
+~~~
+
+Sample expressions:
+
+<!-- placeholder to force blank line before included text --> <!-- include: Expression.test.Expression_result.approved.md -->
+
+~~~text
+'hello' => 'hello'
+"hello" => 'hello'
+"" => ''
+[] => []
+"" || "No value" => 'No value'
+false => false
+true => true
+1 => 1
+0 => 0
+0 || "No value" => 'No value'
+1.0765456 => 1.0765456
+6 * 7 => 42
+["heading1", "heading2"] => ['heading1', 'heading2']
+[1, 2] => [1, 2]
+null => null
+null || "No value" => 'No value'
+undefined => undefined
+undefined || "No value" => 'No value'
+"I _am_ not _italic_".replaceAll("_", "\\_") => 'I \_am\_ not \_italic\_'
+~~~
+
+<!-- placeholder to force blank line after included text --> <!-- endInclude -->
+
+Note:
+
+- Single quotes (`'`) and double quotes (`"`) are generally equivalent and you can use whichever you prefer.
+- The `||` means 'or'. If the expression to the left of the `||` fails, the expression on the right is used instead.
diff --git a/docs/Scripting/Task Properties.md b/docs/Scripting/Task Properties.md
new file mode 100644
index 00000000..5cf5ece8
--- /dev/null
+++ b/docs/Scripting/Task Properties.md
@@ -0,0 +1,93 @@
+---
+publish: true
+---
+
+# Task Properties
+
+<span class="related-pages">#feature/scripting</span>
+
+> [!released]
+> Task Properties were introduced in Tasks X.Y.Z.
+
+## Introduction
+
+In a growing number of locations, Tasks allows programmatic/scripting access to values in your Tasks:
+
+- [[Grouping#Group by Function - Custom Groups]]
+
+This documents all the available pieces of information in Tasks that you can access.
+
+> [!warning]
+> Tasks does store other data, but we only guarantee future support for the data items listed here without warning.
+
+## Values for Task Statuses
+
+For more information, including adding your own customised statuses, see [[Statuses]].
+
+<!-- placeholder to force blank line before included text --> <!-- include: TaskProperties.test.task_status.approved.md -->
+
+| Field | Type 1 | Example 1 | Type 2 | Example 2 |
+| ----- | ----- | ----- | ----- | ----- |
+| `task.status.name` | `string` | `'Todo'` | `string` | `'In Progress'` |
+| `task.status.type` | `string` | `'TODO'` | `string` | `'IN_PROGRESS'` |
+| `task.status.symbol` | `string` | `' '` | `string` | `'/'` |
+| `task.status.nextStatusSymbol` | `string` | `'x'` | `string` | `'x'` |
+
+<!-- placeholder to force blank line after included text --> <!-- endInclude -->
+
+---
+
+## Values for Dates in Tasks
+
+<!-- placeholder to force blank line before included text --> <!-- include: TaskProperties.test.task_dates.approved.md -->
+
+| 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` |
+
+<!-- placeholder to force blank line after included text --> <!-- endInclude -->
+
+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 for Other Task Properties
+
+<!-- placeholder to force blank line before included text --> <!-- include: TaskProperties.test.task_other_fields.approved.md -->
+
+| Field | Type 1 | Example 1 | Type 2 | Example 2 |
+| ----- | ----- | ----- | ----- | ----- |
+| `task.description` | `string` | `'Do exercises #todo #health'` | `string` | `'minimal task'` |
+| `task.urgency` | `number` | `3.3000000000000007` | `number` | `1.9500000000000002` |
+| `task.tags` | `string[]` | `['#todo', '#health']` | `any[]` | `[]` |
+
+<!-- placeholder to force blank line after included text --> <!-- endInclude -->
+
+1. `task.description` has spaces at the start and end stripped off.
+1. `task.description` includes any tags.
+1. Note that if there is a [[Global Filter]] enabled in settings, and the filter is a tag, it will be removed from `task.tags`.
+1. In a future release we will provide access to the task priority.
+
+---
+
+%%
+## Values for File Properties - commented out until precedingHeader renamed to heading
+
+<!-- placeholder to force blank line before included text --> <!-- include: TaskProperties.test.task_file_properties.approved.md -->
+
+| Field | Type 1 | Example 1 | Type 2 | Example 2 |
+| ----- | ----- | ----- | ----- | ----- |
+| `task.precedingHeader` | `string` | `'My Header'` | `null` | `null` |
+
+<!-- placeholder to force blank line after included text --> <!-- endInclude -->
+
+1. `task.precedingHeader` will be replaced by `task.heading` before release.
+1. Access to file properties will be added soon.
+%%
diff --git a/resources/sample_vaults/Tasks-Demo/Functions/Fields for Functions - Demo.md b/resources/sample_vaults/Tasks-Demo/Functions/Fields for Functions - Demo.md
new file mode 100644
index 00000000..398bd4df
--- /dev/null
+++ b/resources/sample_vaults/Tasks-Demo/Functions/Fields for Functions - Demo.md
@@ -0,0 +1,12 @@
+# Fields for Functions - Demo
+
+## Status
+
+```tasks
+group by function task.status.name
+group by function task.status.symbol
+group by function task.status.nextStatusSymbol
+group by function task.status.type
+
+limit 10
+```
diff --git a/src/Query/Filter/FunctionField.ts b/src/Query/Filter/FunctionField.ts
new file mode 100644
index 00000000..638c9096
--- /dev/null
+++ b/src/Query/Filter/FunctionField.ts
@@ -0,0 +1,98 @@
+import { evaluateExpression } from '../../Scripting/Expression';
+import type { Task } from '../../Task';
+import type { GrouperFunction } from '../Grouper';
+import { Grouper } from '../Grouper';
+import { Field } from './Field';
+import { FilterOrErrorMessage } from './Filter';
+
+/**
+ * A {@link Field} implement that accepts a JavaSscript expression to group tasks together.
+ *
+ * See also {@link evaluateExpression}
+ */
+export class FunctionField extends Field {
+    createFilterOrErrorMessage(line: string): FilterOrErrorMessage {
+        return FilterOrErrorMessage.fromError(line, 'Searching by custom function not yet implemented');
+    }
+
+    fieldName(): string {
+        return 'function';
+    }
+
+    protected filterRegExp(): RegExp | null {
+        return null;
+    }
+
+    // -----------------------------------------------------------------------------------------------------------------
+    // Grouping
+    // -----------------------------------------------------------------------------------------------------------------
+
+    public supportsGrouping(): boolean {
+        return true;
+    }
+
+    public createGrouperFromLine(line: string): Grouper | null {
+        const match = Field.getMatch(this.grouperRegExp(), line);
+        if (match === null) {
+            return null;
+        }
+        const reverse = !!match[1];
+        const args = match[2];
+        return new Grouper('function', createGrouperFunctionFromLine(args), reverse);
+    }
+
+    protected grouperRegExp(): RegExp {
+        return new RegExp(`^group by ${this.fieldNameSingularEscaped()}( reverse)? (.*)`);
+    }
+
+    /**
+     * This method does not work for 'group by function' as the user's instruction line
+     * is required in order to create the {@link GrouperFunction}.
+     *
+     * So this class overrides {@link createGrouperFromLine} instead.
+     * @throws Error
+     */
+    public grouper(): GrouperFunction {
+        throw Error('grouper() function not valid for FunctionField. Use createGrouperFromLine() instead.');
+    }
+}
+
+type GroupingArg = string;
+
+function createGrouperFunctionFromLine(line: string): GrouperFunction {
+    return (task: Task) => {
+        return groupByFunction(task, line);
+    };
+}
+
+export function groupByFunction(task: Task, arg: GroupingArg): string[] {
+    try {
+        const result = evaluateExpression(task, arg);
+
+        if (Array.isArray(result)) {
+            return result.map((h) => h.toString());
+        }
+
+        // Task uses null to represent missing information.
+        // So we treat null as an empty group or 'not in a heading', for simplicity for users.
+        // This can be overridden with 'null || "No value"
+        if (result === null) {
+            return [];
+        }
+
+        // If there was an error in the expression, like it referred to
+        // an unknown task field, result will be undefined, and the call
+        // on undefined.toString() will give an exception and a useful error
+        // message below. This is a feature: it gives users feedback on the problem
+        // instruction line.
+        const group = result.toString();
+        return [group];
+    } catch (e) {
+        const errorMessage = `Error: Failed calculating expression "${arg}". The error message was: `;
+        if (e instanceof Error) {
+            return [errorMessage + e.message];
+        } else {
+            return [errorMessage + 'Unknown error'];
+        }
+    }
+}
diff --git a/src/Query/Filter/HappensDateField.ts b/src/Query/Filter/HappensDateField.ts
index 78f203b3..caa020c6 100644
--- a/src/Query/Filter/HappensDateField.ts
+++ b/src/Query/Filter/HappensDateField.ts
@@ -1,6 +1,5 @@
 import type { Moment } from 'moment';
 import type { Task } from '../../Task';
-import { compareByDate } from '../../lib/DateTools';
 import type { FilterFunction } from './Filter';
 import { FilterInstructions } from './FilterInstructions';
 import type { DateFilterFunction } from './DateField';
@@ -38,7 +37,7 @@ export class HappensDateField extends DateField {
      * Return the task's start, scheduled and due dates, any or all of which may be null.
      */
     public dates(task: Task): (Moment | null)[] {
-        return Array.of(task.startDate, task.scheduledDate, task.dueDate);
+        return task.happensDates;
     }
 
     /**
@@ -49,9 +48,7 @@ export class HappensDateField extends DateField {
      * @param task
      */
     public earliestDate(task: Task): Moment | null {
-        const happensDates = new HappensDateField().dates(task);
-        const sortedHappensDates = happensDates.sort(compareByDate);
-        return sortedHappensDates[0];
+        return task.happens;
     }
 
     protected filterResultIfFieldMissing() {
diff --git a/src/Query/FilterParser.ts b/src/Query/FilterParser.ts
index c5366244..78991ba1 100644
--- a/src/Query/FilterParser.ts
+++ b/src/Query/FilterParser.ts
@@ -4,6 +4,7 @@ import { CreatedDateField } from './Filter/CreatedDateField';
 import { DoneDateField } from './Filter/DoneDateField';
 import { DueDateField } from './Filter/DueDateField';
 import { ExcludeSubItemsField } from './Filter/ExcludeSubItemsField';
+import { FunctionField } from './Filter/FunctionField';
 import { HeadingField } from './Filter/HeadingField';
 import { PathField } from './Filter/PathField';
 import { PriorityField } from './Filter/PriorityField';
@@ -55,6 +56,7 @@ const fieldCreators: EndsWith<BooleanField> = [
     () => new FilenameField(),
     () => new UrgencyField(),
     () => new RecurrenceField(),
+    () => new FunctionField(),
     () => new BooleanField(), // --- Please make sure to keep BooleanField last (see comment above) ---
 ];
 
diff --git a/src/Query/GroupingTreeNode.ts b/src/Query/GroupingTreeNode.ts
index 3d85a966..96811a4f 100644
--- a/src/Query/GroupingTreeNode.ts
+++ b/src/Query/GroupingTreeNode.ts
@@ -8,6 +8,11 @@ export class GroupingTreeNode<T> {
     children: Map<string, GroupingTreeNode<T>> = new Map();
     values: T[] = [];
 
+    /**
+     * After the GroupingTreeNode is created, the caller must populate the tree by adding values to {@link children}.
+     * See GroupingTreeNode.test.ts for an explanation of the contents of {@link children}.
+     * @param values
+     */
     constructor(values: T[]) {
         this.values = values;
     }
diff --git a/src/Scripting/Expression.ts b/src/Scripting/Expression.ts
new file mode 100644
index 00000000..9e1aae06
--- /dev/null
+++ b/src/Scripting/Expression.ts
@@ -0,0 +1,40 @@
+import type { Task } from '../Task';
+
+/**
+ * Evaluate an arbitrary JavaScript expression on a Task object
+ * @param task - a {@link Task} object
+ * @param arg - a string, such as `task.path.startsWith("journal/") ? "journal/" : task.path`
+ *
+ * Currently any errors are returned as string error messages, starting with the word 'Error'.
+ *
+ * @todo Implement a type-safe mechanism to report error messages distinct from expression results.
+ *
+ * See also {@link FunctionField} which exposes this facility to users.
+ */
+export function evaluateExpression(task: Task, arg: string | null) {
+    const paramsArgs: [string, any][] = [
+        // TODO Later, pass in the Query too, for access to file properties
+        ['task', task],
+    ];
+
+    const params = paramsArgs.map(([p]) => p);
+    const expression = arg && new Function(...params, `return ${arg}`);
+
+    if (!(expression instanceof Function)) {
+        // I have not managed to write a test that reaches here:
+        return 'Error parsing group function';
+    }
+
+    const args = paramsArgs.map(([_, a]) => a);
+
+    try {
+        return expression(...args);
+    } catch (e) {
+        const errorMessage = `Error: Failed calculating expression "${arg}". The error message was: `;
+        if (e instanceof Error) {
+            return errorMessage + e.message;
+        } else {
+            return errorMessage + 'Unknown error';
+        }
+    }
+}
diff --git a/src/Task.ts b/src/Task.ts
index b222038a..d280af5a 100644
--- a/src/Task.ts
+++ b/src/Task.ts
@@ -33,6 +33,7 @@ export enum Priority {
 
 export class TaskRegularExpressions {
     public static readonly dateFormat = 'YYYY-MM-DD';
+    public static readonly dateTimeFormat = 'YYYY-MM-DD HH:mm';
 
     // Matches indentation before a list marker (including > for potentially nested blockquotes or Obsidian callouts)
     public static readonly indentationRegex = /^([\s\t>]*)/;
@@ -407,6 +408,74 @@ export class Task {
         return this.taskLocation.path;
     }
 
+    /**
+     * An alias for {@link createdDate}, so the field names in scripting docs are consistent with the existing search instruction names.
+     */
+    public get created(): Moment | null {
+        return this.createdDate;
+    }
+
+    /**
+     * An alias for {@link doneDate}, so the field names in scripting docs are consistent with the existing search instruction names.
+     */
+    public get done(): Moment | null {
+        return this.doneDate;
+    }
+
+    /**
+     * An alias for {@link dueDate}, so the field names in scripting docs are consistent with the existing search instruction names.
+     */
+    public get due(): Moment | null {
+        return this.dueDate;
+    }
+
+    /**
+     * An alias for {@link scheduledDate}, so the field names in scripting docs are consistent with the existing search instruction names.
+     */
+    public get scheduled(): Moment | null {
+        return this.scheduledDate;
+    }
+
+    /**
+     * An alias for {@link startDate}, so the field names in scripting docs are consistent with the existing search instruction names.
+     */
+    public get start(): Moment | null {
+        return this.startDate;
+    }
+
+    /**
+     * Return the date fields that contribute to 'happens' searches.
+     *
+     * @see happens
+     * @see {@link HappensDateField}
+     */
+    public get happensDates(): (Moment | null)[] {
+        return Array.of(this.startDate, this.scheduledDate, this.dueDate);
+    }
+
+    /**
+     * Return the earliest of the dates used by 'happens' in this given task, or null if none set.
+     *
+     * Generally speaking, the earliest date is considered to be the highest priority,
+     * as it is the first point at which the user might wish to act on the task.
+     *
+     * @see happensDates
+     * @see {@link HappensDateField}
+     */
+    public get happens(): Moment | null {
+        const happensDates = this.happensDates;
+        const sortedHappensDates = happensDates.sort(compareByDate);
+        return sortedHappensDates[0];
+    }
+
+    /**
+     * Return true if the Task has a valid recurrence rule, and false otherwise,
+     * that is, false if it does not have a recurrence rule, or the recurrence rule is invalid.
+     */
+    public get recurring(): boolean {
+        return this.recurrence !== null;
+    }
+
     /**
      * Return the name of the file containing the task, with the .md extension removed.
      */


Test patch:
diff --git a/tests/CustomMatchers/CustomMatchersForGrouping.ts b/tests/CustomMatchers/CustomMatchersForGrouping.ts
index ec3f53f1..a42f834b 100644
--- a/tests/CustomMatchers/CustomMatchersForGrouping.ts
+++ b/tests/CustomMatchers/CustomMatchersForGrouping.ts
@@ -1,8 +1,9 @@
 import { diff } from 'jest-diff';
 import type { Grouper } from 'Query/Grouper';
 import type { Task } from 'Task';
-import { TaskGroups } from '../../src/Query/TaskGroups';
 import type { Field } from '../../src/Query/Filter/Field';
+import { TaskGroups } from '../../src/Query/TaskGroups';
+import { TaskBuilder } from '../TestingTools/TaskBuilder';
 
 declare global {
     namespace jest {
@@ -47,19 +48,29 @@ export function toSupportGroupingWithProperty(field: Field, property: string) {
     };
 }
 
-export function groupHeadingsToBe(
-    { grouper, tasks }: { grouper: Grouper; tasks: Task[] },
-    expectedGroupHeadings: string[],
-): jest.CustomMatcherResult {
-    tasks.sort(() => Math.random() - 0.5);
+/**
+ * Collate all the headings obtained when grouping the tasks by the grouper
+ * @param grouper
+ * @param tasks
+ */
+export function groupHeadingsForTask(grouper: Grouper, tasks: Task[]) {
     const groups = new TaskGroups([grouper], tasks);
 
-    const groupHeadings: string[] = [];
+    const headings: string[] = [];
     groups.groups.forEach((taskGroup) => {
         taskGroup.groupHeadings.forEach((heading) => {
-            groupHeadings.push(heading.displayName);
+            headings.push(heading.displayName);
         });
     });
+    return headings;
+}
+
+export function groupHeadingsToBe(
+    { grouper, tasks }: { grouper: Grouper; tasks: Task[] },
+    expectedGroupHeadings: string[],
+): jest.CustomMatcherResult {
+    tasks.sort(() => Math.random() - 0.5);
+    const groupHeadings = groupHeadingsForTask(grouper, tasks);
 
     const pass: boolean = groupHeadings.join() === expectedGroupHeadings.join();
     const message: () => string = () =>
@@ -72,3 +83,46 @@ export function groupHeadingsToBe(
         pass,
     };
 }
+
+/**
+ * Test that applying the grouper to the task generates the expected group names.
+ * @param grouper
+ * @param task
+ * @param expectedGroupNames
+ */
+export function toGroupTask(grouper: Grouper | null, task: Task, expectedGroupNames: string[]) {
+    if (grouper === undefined) {
+        return {
+            message: () => 'unexpected null grouper: check your instruction matches your filter class.',
+            pass: false,
+        };
+    }
+
+    expect(grouper!.grouper(task)).toEqual(expectedGroupNames);
+}
+
+/**
+ * Test that applying the grouper to the task created by the builder generates the expected group names.
+ * @param grouper
+ * @param taskBuilder
+ * @param expectedGroupNames
+ */
+export function toGroupTaskFromBuilder(
+    grouper: Grouper | null,
+    taskBuilder: TaskBuilder,
+    expectedGroupNames: string[],
+) {
+    const task = taskBuilder.build();
+    toGroupTask(grouper, task, expectedGroupNames);
+}
+
+/**
+ * Test that applying the grouper to a task in the given file path generates the expected group names.
+ * @param grouper
+ * @param path
+ * @param expectedGroupNames
+ */
+export function toGroupTaskWithPath(grouper: Grouper | null, path: string, expectedGroupNames: string[]) {
+    const taskBuilder = new TaskBuilder().path(path);
+    toGroupTaskFromBuilder(grouper, taskBuilder, expectedGroupNames);
+}
diff --git a/tests/Query.test.ts b/tests/Query.test.ts
index bcc93a10..3331789a 100644
--- a/tests/Query.test.ts
+++ b/tests/Query.test.ts
@@ -212,6 +212,8 @@ describe('Query parsing', () => {
             'group by filename reverse',
             'group by folder',
             'group by folder reverse',
+            'group by function task.status.symbol.replace(" ", "space")',
+            'group by function reverse task.status.symbol.replace(" ", "space")',
             'group by happens',
             'group by happens reverse',
             'group by heading',
diff --git a/tests/Query/Filter/FunctionField.test.ts b/tests/Query/Filter/FunctionField.test.ts
new file mode 100644
index 00000000..f02f8167
--- /dev/null
+++ b/tests/Query/Filter/FunctionField.test.ts
@@ -0,0 +1,211 @@
+/**
+ * @jest-environment jsdom
+ */
+import moment from 'moment';
+
+import { FunctionField } from '../../../src/Query/Filter/FunctionField';
+import { Status } from '../../../src/Status';
+import { Priority } from '../../../src/Task';
+import { toGroupTaskFromBuilder, toGroupTaskWithPath } from '../../CustomMatchers/CustomMatchersForGrouping';
+import { TaskBuilder } from '../../TestingTools/TaskBuilder';
+
+window.moment = moment;
+
+// -----------------------------------------------------------------------------------------------------------------
+// Filtering
+// -----------------------------------------------------------------------------------------------------------------
+
+describe('FunctionField - filtering', () => {
+    it('should not parse line', () => {
+        const functionField = new FunctionField();
+        const filterOrErrorMessage = functionField.createFilterOrErrorMessage('hello world');
+        expect(filterOrErrorMessage).not.toBeValid();
+        expect(filterOrErrorMessage.error).toStrictEqual('Searching by custom function not yet implemented');
+    });
+});
+
+// -----------------------------------------------------------------------------------------------------------------
+// Sorting
+// -----------------------------------------------------------------------------------------------------------------
+
+describe('FunctionField - sorting', () => {
+    it('should not support sorting', () => {
+        const functionField = new FunctionField();
+        expect(functionField.supportsSorting()).toEqual(false);
+    });
+});
+
+// -----------------------------------------------------------------------------------------------------------------
+// Grouping
+// -----------------------------------------------------------------------------------------------------------------
+
+afterEach(() => {
+    jest.useRealTimers();
+});
+
+function createGrouper(line: string) {
+    const grouper = new FunctionField().createGrouperFromLine(line);
+    expect(grouper).not.toBeNull();
+    return grouper;
+}
+
+describe('FunctionField - grouping - basics', () => {
+    it('should support grouping', () => {
+        const field = new FunctionField();
+        expect(field.supportsGrouping()).toEqual(true);
+    });
+
+    it('should parse "group by function" line', () => {
+        // Arrange
+        const field = new FunctionField();
+        const instruction = 'group by function task.path.startsWith("journal/") ? "journal/" : task.path';
+
+        // Assert
+        const grouper = field.createGrouperFromLine(instruction);
+        expect(grouper).not.toBeNull();
+        expect(grouper?.reverse).toEqual(false);
+    });
+
+    it('should parse "group by function reverse" line', () => {
+        // Arrange
+        const field = new FunctionField();
+        const instruction = 'group by function reverse task.path.startsWith("journal/") ? "journal/" : task.path';
+
+        // Assert
+        const grouper = field.createGrouperFromLine(instruction);
+        expect(grouper).not.toBeNull();
+        expect(grouper?.reverse).toEqual(true);
+    });
+});
+
+describe('FunctionField - grouping - error-handling', () => {
+    it('should throw in grouper()', () => {
+        // It's not possible to create a GrouperFunction without a line,
+        // so grouper() method needs to throw and report it cannot work.
+        const field = new FunctionField();
+        const t = () => {
+            field.grouper();
+        };
+        expect(t).toThrow(Error);
+    });
+
+    it('should give a meaningful error for invalid syntax', () => {
+        const line = 'group by function hello';
+        const grouper = createGrouper(line);
+        toGroupTaskWithPath(grouper, 'journal/a/b.md', [
+            'Error: Failed calculating expression "hello". The error message was: hello is not defined',
+        ]);
+    });
+
+    it('should give a meaningful error for unknown task field', () => {
+        const line = 'group by function task.nonExistentField';
+        const grouper = createGrouper(line);
+        toGroupTaskFromBuilder(grouper, new TaskBuilder(), [
+            'Error: Failed calculating expression "task.nonExistentField". The error message was: Cannot read properties of undefined (reading \'toString\')',
+        ]);
+    });
+});
+
+describe('FunctionField - grouping return types', () => {
+    const expressionsAndResults = [
+        ['"hello"', ['hello']],
+        ['""', ['']],
+        ['[]', []],
+        ['"" || "No value"', ['No value']],
+        ['false', ['false']],
+        ['true', ['true']],
+        ['1', ['1']],
+        ['0', ['0']],
+        ['0 || "No value"', ['No value']],
+        ['1.0765456', ['1.0765456']],
+        ['1.0765456.toFixed(3)', ['1.077']],
+        ['["heading1", "heading2"]', ['heading1', 'heading2']], // return two headings, indicating that this task should be displayed twice, once in each heading
+        ['[1, 2]', ['1', '2']], // return two headings, that need to be converted to strings
+        ['null', []],
+        ['null || "No value"', ['No value']],
+        [
+            'undefined',
+            [
+                'Error: Failed calculating expression "undefined". The error message was: Cannot read properties of undefined (reading \'toString\')',
+            ],
+        ],
+        ['undefined || "No value"', ['No value']],
+        [
+            // Should allow manual escaping of markdown
+            String.raw`"I _am_ not _italic_".replaceAll("_", "\\_")`,
+            [String.raw`I \_am\_ not \_italic\_`],
+        ],
+    ];
+
+    it.each(expressionsAndResults)("expression: '%s'", (expression: any, result: any) => {
+        const line = `group by function ${expression}`;
+        const grouper = createGrouper(line);
+        toGroupTaskFromBuilder(grouper, new TaskBuilder(), result);
+    });
+});
+
+describe('FunctionField - grouping - example functions', () => {
+    it('should display booleans in a meaningful way', () => {
+        const line = "group by function task.status.symbol === '/'";
+        const grouper = createGrouper(line);
+        toGroupTaskWithPath(grouper, 'journal/a/b.md', ['false']);
+    });
+
+    it('using root and path', () => {
+        // Note: This will change to task.file.path
+        const line = 'group by function task.path.startsWith("journal/") ? "journal/" : task.path';
+        const grouper = createGrouper(line);
+
+        toGroupTaskWithPath(grouper, 'journal/a/b.md', ['journal/']);
+        toGroupTaskWithPath(grouper, 'hello/world/from-me.md', ['hello/world/from-me.md']);
+        toGroupTaskWithPath(grouper, 'file-in-root-folder.md', ['file-in-root-folder.md']);
+    });
+
+    it('using path stripping folder', () => {
+        const line = 'group by function task.path.replace("some/prefix/", "")';
+        const grouper = createGrouper(line);
+
+        toGroupTaskWithPath(grouper, 'a/b/c.md', ['a/b/c.md']);
+    });
+
+    it('group by priority', () => {
+        const line = 'group by function task.priority';
+        const grouper = createGrouper(line);
+        toGroupTaskFromBuilder(grouper, new TaskBuilder().priority(Priority.Highest), ['0']);
+    });
+
+    it('group by status symbol', () => {
+        // A single space as the character in a heading is not useful, so replace with something displayable:
+        const line = 'group by function task.status.symbol.replace(" ", "space")';
+        const grouper = createGrouper(line);
+        toGroupTaskFromBuilder(grouper, new TaskBuilder().status(Status.makeCancelled()), ['-']);
+        toGroupTaskFromBuilder(grouper, new TaskBuilder().status(Status.makeTodo()), ['space']);
+    });
+
+    it('group by status nextStatusSymbol', () => {
+        const line = 'group by function task.status.nextStatusSymbol.replace(" ", "space")';
+        const grouper = createGrouper(line);
+        toGroupTaskFromBuilder(grouper, new TaskBuilder().status(Status.makeInProgress()), ['x']);
+        toGroupTaskFromBuilder(grouper, new TaskBuilder().status(Status.makeDone()), ['space']);
+    });
+
+    it('group by using number', () => {
+        const line = 'group by function task.description.length';
+        const grouper = createGrouper(line);
+        toGroupTaskFromBuilder(grouper, new TaskBuilder().description('#task Hello'), ['11']);
+    });
+
+    it('group by using string literal', () => {
+        const line = 'group by function reverse "Description length: " + task.description.length';
+        const grouper = createGrouper(line);
+        toGroupTaskFromBuilder(grouper, new TaskBuilder().description('#task Hello'), ['Description length: 11']);
+    });
+
+    it('group by a selected tag', () => {
+        const line = "group by function task.tags.find((tag) => tag.includes('#context/'))";
+        const grouper = createGrouper(line);
+        toGroupTaskFromBuilder(grouper, new TaskBuilder().tags(['#context/pc_home', '#topic/sys_admin']), [
+            '#context/pc_home',
+        ]);
+    });
+});
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.blockLink_docs.approved.md b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.blockLink_docs.approved.md
new file mode 100644
index 00000000..1e6b83b6
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.blockLink_docs.approved.md
@@ -0,0 +1,12 @@
+<!-- placeholder to force blank line before included text -->
+
+
+~~~text
+group by function task.blockLink.replace(' ^', '')
+~~~
+
+- DO NOT RELEASE UNTIL THE LEADING SPACE IS REMOVED FROM BLOCKLINKS. Removing the leading space and carat prevents the rendered heading itself being treated as a blocklink..
+
+
+
+<!-- placeholder to force blank line after included text -->
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.blockLink_results.approved.txt b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.blockLink_results.approved.txt
new file mode 100644
index 00000000..e843157f
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.blockLink_results.approved.txt
@@ -0,0 +1,11 @@
+Results of custom groupers
+
+
+
+group by function task.blockLink.replace(' ^', '')
+DO NOT RELEASE UNTIL THE LEADING SPACE IS REMOVED FROM BLOCKLINKS. Removing the leading space and carat prevents the rendered heading itself being treated as a blocklink.
+=>
+ca47c7
+fromseparatefile
+====================================================================================
+
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
new file mode 100644
index 00000000..42b6fd1f
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.created_docs.approved.md
@@ -0,0 +1,12 @@
+<!-- placeholder to force blank line before included text -->
+
+
+~~~text
+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.
+
+
+
+<!-- placeholder to force blank line after included text -->
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
new file mode 100644
index 00000000..012e7115
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.created_results.approved.txt
@@ -0,0 +1,13 @@
+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
+=>
+2023-05-30 Tuesday
+2023-05-31 Wednesday
+2023-06-01 Thursday
+Invalid date
+====================================================================================
+
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.description_docs.approved.md b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.description_docs.approved.md
new file mode 100644
index 00000000..5fdfc6f1
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.description_docs.approved.md
@@ -0,0 +1,33 @@
+<!-- placeholder to force blank line before included text -->
+
+
+~~~text
+group by function task.description
+~~~
+
+- group by description. This might be useful for finding completed recurrences of the same task.
+
+
+~~~text
+group by function task.description.toUpperCase()
+~~~
+
+- Convert the description to capitals.
+
+
+~~~text
+group by function task.description.slice(0, 25)
+~~~
+
+- Truncate descriptions to at most their first 25 characters, and group by that string.
+
+
+~~~text
+group by function task.description.replace('short', '==short==')
+~~~
+
+- Highlight the word "short" in any group descriptions.
+
+
+
+<!-- placeholder to force blank line after included text -->
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.description_results.approved.txt b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.description_results.approved.txt
new file mode 100644
index 00000000..52007a49
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.description_results.approved.txt
@@ -0,0 +1,35 @@
+Results of custom groupers
+
+
+
+group by function task.description
+group by description. This might be useful for finding completed recurrences of the same task
+=>
+long description Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce quam ipsum, consectetur ut dolor nec, fringilla lobortis mi. Vestibulum gravida tincidunt urna nec ornare. Cras sit amet sagittis sapien, vitae mattis velit. Vestibulum sem tortor, blandit at ultrices eget, ultrices eget odio. Donec efficitur purus massa, vel molestie turpis tincidunt id. 
+short description
+====================================================================================
+
+
+group by function task.description.toUpperCase()
+Convert the description to capitals
+=>
+LONG DESCRIPTION LOREM IPSUM DOLOR SIT AMET, CONSECTETUR ADIPISCING ELIT. FUSCE QUAM IPSUM, CONSECTETUR UT DOLOR NEC, FRINGILLA LOBORTIS MI. VESTIBULUM GRAVIDA TINCIDUNT URNA NEC ORNARE. CRAS SIT AMET SAGITTIS SAPIEN, VITAE MATTIS VELIT. VESTIBULUM SEM TORTOR, BLANDIT AT ULTRICES EGET, ULTRICES EGET ODIO. DONEC EFFICITUR PURUS MASSA, VEL MOLESTIE TURPIS TINCIDUNT ID. 
+SHORT DESCRIPTION
+====================================================================================
+
+
+group by function task.description.slice(0, 25)
+Truncate descriptions to at most their first 25 characters, and group by that string
+=>
+long description Lorem ip
+short description
+====================================================================================
+
+
+group by function task.description.replace('short', '==short==')
+Highlight the word "short" in any group descriptions
+=>
+==short== description
+long description Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce quam ipsum, consectetur ut dolor nec, fringilla lobortis mi. Vestibulum gravida tincidunt urna nec ornare. Cras sit amet sagittis sapien, vitae mattis velit. Vestibulum sem tortor, blandit at ultrices eget, ultrices eget odio. Donec efficitur purus massa, vel molestie turpis tincidunt id. 
+====================================================================================
+
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
new file mode 100644
index 00000000..0a1cda75
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.done_docs.approved.md
@@ -0,0 +1,12 @@
+<!-- placeholder to force blank line before included text -->
+
+
+~~~text
+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.
+
+
+
+<!-- placeholder to force blank line after included text -->
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
new file mode 100644
index 00000000..ad765b68
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.done_results.approved.txt
@@ -0,0 +1,13 @@
+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
+=>
+2023-05-30 Tuesday
+2023-05-31 Wednesday
+2023-06-01 Thursday
+Invalid date
+====================================================================================
+
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
new file mode 100644
index 00000000..112948b5
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.due_docs.approved.md
@@ -0,0 +1,40 @@
+<!-- placeholder to force blank line before included text -->
+
+
+~~~text
+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.
+
+
+~~~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 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 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 the time from now, for example "8 days ago". Whilst interesting, the alphabetical sort order makes the headings a little hard to read.
+
+
+
+<!-- placeholder to force blank line after included text -->
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
new file mode 100644
index 00000000..b312dc32
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.due_results.approved.txt
@@ -0,0 +1,53 @@
+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
+=>
+2023-05-30 Tuesday
+2023-05-31 Wednesday
+2023-06-01 Thursday
+Invalid date
+====================================================================================
+
+
+group by function task.due?.format("dddd") || ""
+Group by day of the week (Monday, Tuesday, etc)
+=>
+Invalid date
+Thursday
+Tuesday
+Wednesday
+====================================================================================
+
+
+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
+2023 06 Jun
+Invalid date
+no due 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
+2023-06 Jun - Week 22
+Invalid date
+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
+=>
+10 days ago
+11 days ago
+12 days ago
+Invalid date
+====================================================================================
+
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
new file mode 100644
index 00000000..855d7dea
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.happens_docs.approved.md
@@ -0,0 +1,12 @@
+<!-- placeholder to force blank line before included text -->
+
+
+~~~text
+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.
+
+
+
+<!-- placeholder to force blank line after included text -->
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
new file mode 100644
index 00000000..3c459753
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.happens_results.approved.txt
@@ -0,0 +1,13 @@
+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
+=>
+2023-05-30 Tuesday
+2023-05-31 Wednesday
+2023-06-01 Thursday
+Invalid date
+====================================================================================
+
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.priority_docs.approved.md b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.priority_docs.approved.md
new file mode 100644
index 00000000..a7ba35a0
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.priority_docs.approved.md
@@ -0,0 +1,12 @@
+<!-- placeholder to force blank line before included text -->
+
+
+~~~text
+group by function task.priority
+~~~
+
+- Group by the task's priority number, where Highest is 0 and Lowest is 5.
+
+
+
+<!-- placeholder to force blank line after included text -->
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.priority_results.approved.txt b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.priority_results.approved.txt
new file mode 100644
index 00000000..ed6f6213
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.priority_results.approved.txt
@@ -0,0 +1,15 @@
+Results of custom groupers
+
+
+
+group by function task.priority
+Group by the task's priority number, where Highest is 0 and Lowest is 5
+=>
+0
+1
+2
+3
+4
+5
+====================================================================================
+
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
new file mode 100644
index 00000000..13c714ca
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.scheduled_docs.approved.md
@@ -0,0 +1,12 @@
+<!-- placeholder to force blank line before included text -->
+
+
+~~~text
+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.
+
+
+
+<!-- placeholder to force blank line after included text -->
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
new file mode 100644
index 00000000..07430dc1
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.scheduled_results.approved.txt
@@ -0,0 +1,13 @@
+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
+=>
+2023-05-30 Tuesday
+2023-05-31 Wednesday
+2023-06-01 Thursday
+Invalid date
+====================================================================================
+
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
new file mode 100644
index 00000000..815eb118
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.start_docs.approved.md
@@ -0,0 +1,12 @@
+<!-- placeholder to force blank line before included text -->
+
+
+~~~text
+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.
+
+
+
+<!-- placeholder to force blank line after included text -->
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
new file mode 100644
index 00000000..952b0067
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.start_results.approved.txt
@@ -0,0 +1,13 @@
+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
+=>
+2023-05-30 Tuesday
+2023-05-31 Wednesday
+2023-06-01 Thursday
+Invalid date
+====================================================================================
+
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.status.name_docs.approved.md b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.status.name_docs.approved.md
new file mode 100644
index 00000000..73a15614
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.status.name_docs.approved.md
@@ -0,0 +1,19 @@
+<!-- placeholder to force blank line before included text -->
+
+
+~~~text
+group by function task.status.name
+~~~
+
+- Identical to "group by status.name".
+
+
+~~~text
+group by function task.status.name.toUpperCase()
+~~~
+
+- Convert the status names to capitals.
+
+
+
+<!-- placeholder to force blank line after included text -->
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.status.name_results.approved.txt b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.status.name_results.approved.txt
new file mode 100644
index 00000000..cd129592
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.status.name_results.approved.txt
@@ -0,0 +1,25 @@
+Results of custom groupers
+
+
+
+group by function task.status.name
+Identical to "group by status.name"
+=>
+Cancelled
+Done
+EMPTY
+In Progress
+Todo
+====================================================================================
+
+
+group by function task.status.name.toUpperCase()
+Convert the status names to capitals
+=>
+CANCELLED
+DONE
+EMPTY
+IN PROGRESS
+TODO
+====================================================================================
+
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.status.nextStatusSymbol_docs.approved.md b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.status.nextStatusSymbol_docs.approved.md
new file mode 100644
index 00000000..be6fee38
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.status.nextStatusSymbol_docs.approved.md
@@ -0,0 +1,12 @@
+<!-- placeholder to force blank line before included text -->
+
+
+~~~text
+group by function "Next status symbol: " + task.status.nextStatusSymbol.replace(" ", "space")
+~~~
+
+- Group by the next status symbol, making space characters visible.
+
+
+
+<!-- placeholder to force blank line after included text -->
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.status.nextStatusSymbol_results.approved.txt b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.status.nextStatusSymbol_results.approved.txt
new file mode 100644
index 00000000..adf5137c
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.status.nextStatusSymbol_results.approved.txt
@@ -0,0 +1,12 @@
+Results of custom groupers
+
+
+
+group by function "Next status symbol: " + task.status.nextStatusSymbol.replace(" ", "space")
+Group by the next status symbol, making space characters visible
+=>
+Next status symbol: 
+Next status symbol: space
+Next status symbol: x
+====================================================================================
+
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.status.symbol_docs.approved.md b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.status.symbol_docs.approved.md
new file mode 100644
index 00000000..28d799fa
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.status.symbol_docs.approved.md
@@ -0,0 +1,12 @@
+<!-- placeholder to force blank line before included text -->
+
+
+~~~text
+group by function "Status symbol: " + task.status.symbol.replace(" ", "space")
+~~~
+
+- Group by the status symbol, making space characters visible.
+
+
+
+<!-- placeholder to force blank line after included text -->
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.status.symbol_results.approved.txt b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.status.symbol_results.approved.txt
new file mode 100644
index 00000000..47ceaba7
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.status.symbol_results.approved.txt
@@ -0,0 +1,14 @@
+Results of custom groupers
+
+
+
+group by function "Status symbol: " + task.status.symbol.replace(" ", "space")
+Group by the status symbol, making space characters visible
+=>
+Status symbol: 
+Status symbol: -
+Status symbol: /
+Status symbol: space
+Status symbol: x
+====================================================================================
+
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.status.type_docs.approved.md b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.status.type_docs.approved.md
new file mode 100644
index 00000000..b436d7c1
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.status.type_docs.approved.md
@@ -0,0 +1,12 @@
+<!-- placeholder to force blank line before included text -->
+
+
+~~~text
+group by function task.status.type
+~~~
+
+- Unlike "group by status.type", this sorts the status types in alphabetical order.
+
+
+
+<!-- placeholder to force blank line after included text -->
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.status.type_results.approved.txt b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.status.type_results.approved.txt
new file mode 100644
index 00000000..dea80152
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.status.type_results.approved.txt
@@ -0,0 +1,14 @@
+Results of custom groupers
+
+
+
+group by function task.status.type
+Unlike "group by status.type", this sorts the status types in alphabetical order
+=>
+CANCELLED
+DONE
+EMPTY
+IN_PROGRESS
+TODO
+====================================================================================
+
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.tags_docs.approved.md b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.tags_docs.approved.md
new file mode 100644
index 00000000..ce9b4a03
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.tags_docs.approved.md
@@ -0,0 +1,33 @@
+<!-- placeholder to force blank line before included text -->
+
+
+~~~text
+group by function task.tags
+~~~
+
+- Like "group by tags" except that tasks with no tags have no heading instead of "(No tags)".
+
+
+~~~text
+group by function task.tags.join(", ")
+~~~
+
+- Tasks with multiple tags are listed once, with a heading that combines all the tags. Separating with commas means the tags are clickable in the headings.
+
+
+~~~text
+group by function task.tags.filter( (t) => t.includes("#context/"))
+~~~
+
+- Only create headings for tags that contain "#context/".
+
+
+~~~text
+group by function task.tags.filter( (t) => ! t.includes("#tag"))
+~~~
+
+- Create headings for all tags that do not contain "#tag".
+
+
+
+<!-- placeholder to force blank line after included text -->
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.tags_results.approved.txt b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.tags_results.approved.txt
new file mode 100644
index 00000000..a27cee73
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.tags_results.approved.txt
@@ -0,0 +1,49 @@
+Results of custom groupers
+
+
+
+group by function task.tags
+Like "group by tags" except that tasks with no tags have no heading instead of "(No tags)"
+=>
+#context/home
+#context/work
+#multiple-tags1
+#multiple-tags2
+#project/project1
+#tag
+#tag/subtag
+#tag/subtag/sub-sub-tag
+====================================================================================
+
+
+group by function task.tags.join(", ")
+Tasks with multiple tags are listed once, with a heading that combines all the tags. Separating with commas means the tags are clickable in the headings
+=>
+#context/home
+#context/work
+#multiple-tags1, #multiple-tags2
+#project/project1
+#tag
+#tag/subtag
+#tag/subtag/sub-sub-tag
+====================================================================================
+
+
+group by function task.tags.filter( (t) => t.includes("#context/"))
+Only create headings for tags that contain "#context/"
+=>
+#context/home
+#context/work
+====================================================================================
+
+
+group by function task.tags.filter( (t) => ! t.includes("#tag"))
+Create headings for all tags that do not contain "#tag"
+=>
+#context/home
+#context/work
+#multiple-tags1
+#multiple-tags2
+#project/project1
+====================================================================================
+
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.urgency_docs.approved.md b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.urgency_docs.approved.md
new file mode 100644
index 00000000..d7675286
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.urgency_docs.approved.md
@@ -0,0 +1,12 @@
+<!-- placeholder to force blank line before included text -->
+
+
+~~~text
+group by function task.urgency.toFixed(3)
+~~~
+
+- Show the urgency to 3 decimal places, unlike the built-in "group by urgency" which uses 2.
+
+
+
+<!-- placeholder to force blank line after included text -->
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.urgency_results.approved.txt b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.urgency_results.approved.txt
new file mode 100644
index 00000000..4ebd4a79
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.custom_grouping_by_task.urgency_results.approved.txt
@@ -0,0 +1,15 @@
+Results of custom groupers
+
+
+
+group by function task.urgency.toFixed(3)
+Show the urgency to 3 decimal places, unlike the built-in "group by urgency" which uses 2
+=>
+-1.800
+0.000
+1.950
+3.900
+6.000
+9.000
+====================================================================================
+
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.ts b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.ts
new file mode 100644
index 00000000..684e3816
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/TaskPropertyExamples.test.ts
@@ -0,0 +1,256 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import moment from 'moment';
+
+import type { Task } from '../../../../../src/Task';
+import { SampleTasks } from '../../../../TestHelpers';
+import {
+    verifyFunctionFieldGrouperSamplesForDocs,
+    verifyFunctionFieldGrouperSamplesOnTasks,
+} from './VerifyFunctionFieldSamples';
+
+window.moment = moment;
+
+beforeEach(() => {
+    jest.useFakeTimers();
+    jest.setSystemTime(new Date('2023-06-10 20:00'));
+});
+
+afterEach(() => {
+    jest.useRealTimers();
+});
+
+describe('custom grouping by', () => {
+    type CustomGroupingPropertyTestData = [string, string[][], Task[]];
+    const testData: CustomGroupingPropertyTestData[] = [
+        // ---------------------------------------------------------------------------------
+        // DATE FIELDS
+        // ---------------------------------------------------------------------------------
+
+        [
+            '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',
+                ],
+            ],
+            SampleTasks.withAllRepresentativeCreatedDates(),
+        ],
+
+        [
+            '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',
+                ],
+            ],
+            SampleTasks.withAllRepresentativeDoneDates(),
+        ],
+
+        [
+            '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("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 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',
+                ],
+            ],
+            SampleTasks.withAllRepresentativeDueDates(),
+        ],
+
+        [
+            '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',
+                ],
+            ],
+            SampleTasks.withAllRepresentativeDueDates(),
+        ],
+
+        [
+            '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',
+                ],
+            ],
+            SampleTasks.withAllRepresentativeScheduledDates(),
+        ],
+
+        [
+            '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',
+                ],
+            ],
+            SampleTasks.withAllRepresentativeStartDates(),
+        ],
+
+        // ---------------------------------------------------------------------------------
+        // OTHER FIELDS
+        // ---------------------------------------------------------------------------------
+        [
+            'task.blockLink',
+            [
+                [
+                    "group by function task.blockLink.replace(' ^', '')",
+                    'DO NOT RELEASE UNTIL THE LEADING SPACE IS REMOVED FROM BLOCKLINKS. Removing the leading space and carat prevents the rendered heading itself being treated as a blocklink.',
+                ],
+            ],
+            SampleTasks.withAllRepresentativeBlockLinks(),
+        ],
+
+        [
+            'task.description',
+            [
+                [
+                    'group by function task.description',
+                    'group by description. This might be useful for finding completed recurrences of the same task',
+                ],
+                ['group by function task.description.toUpperCase()', 'Convert the description to capitals'],
+                [
+                    'group by function task.description.slice(0, 25)',
+                    'Truncate descriptions to at most their first 25 characters, and group by that string',
+                ],
+                [
+                    "group by function task.description.replace('short', '==short==')",
+                    'Highlight the word "short" in any group descriptions',
+                ],
+            ],
+            SampleTasks.withAllRepresentativeDescriptions(),
+        ],
+
+        // [
+        //     'task.indentation',
+        //     [['group by function task.indentation', '...']],
+        //     SampleTasks.withAllPriorities(), // TODO Choose specific tasks for task.indentation'
+        // ],
+
+        // [
+        //     'task.listMarker',
+        //     [['group by function task.listMarker', '...']],
+        //     SampleTasks.withAllPriorities(), // TODO Choose specific tasks for task.listMarker'
+        // ],
+
+        [
+            'task.priority',
+            [
+                [
+                    'group by function task.priority',
+                    "Group by the task's priority number, where Highest is 0 and Lowest is 5",
+                ],
+            ],
+            SampleTasks.withAllPriorities(),
+        ],
+
+        [
+            'task.status.name',
+            [
+                ['group by function task.status.name', 'Identical to "group by status.name"'],
+                ['group by function task.status.name.toUpperCase()', 'Convert the status names to capitals'],
+            ],
+            SampleTasks.withAllStatuses(),
+        ],
+
+        [
+            'task.status.nextStatusSymbol',
+            [
+                [
+                    'group by function "Next status symbol: " + task.status.nextStatusSymbol.replace(" ", "space")',
+                    'Group by the next status symbol, making space characters visible',
+                ],
+            ],
+            SampleTasks.withAllStatuses(),
+        ],
+
+        [
+            'task.status.symbol',
+            [
+                [
+                    'group by function "Status symbol: " + task.status.symbol.replace(" ", "space")',
+                    'Group by the status symbol, making space characters visible',
+                ],
+            ],
+            SampleTasks.withAllStatuses(),
+        ],
+
+        [
+            'task.status.type',
+            [
+                [
+                    'group by function task.status.type',
+                    'Unlike "group by status.type", this sorts the status types in alphabetical order',
+                ],
+            ],
+            SampleTasks.withAllStatuses(),
+        ],
+
+        [
+            'task.tags',
+            [
+                [
+                    'group by function task.tags',
+                    'Like "group by tags" except that tasks with no tags have no heading instead of "(No tags)"',
+                ],
+                [
+                    'group by function task.tags.join(", ")',
+                    'Tasks with multiple tags are listed once, with a heading that combines all the tags. Separating with commas means the tags are clickable in the headings',
+                ],
+                [
+                    'group by function task.tags.filter( (t) => t.includes("#context/"))',
+                    'Only create headings for tags that contain "#context/"',
+                ],
+                [
+                    'group by function task.tags.filter( (t) => ! t.includes("#tag"))',
+                    'Create headings for all tags that do not contain "#tag"',
+                ],
+            ],
+            SampleTasks.withRepresentativeTags(),
+        ],
+
+        [
+            'task.urgency',
+            [
+                [
+                    'group by function task.urgency.toFixed(3)',
+                    'Show the urgency to 3 decimal places, unlike the built-in "group by urgency" which uses 2',
+                ],
+            ],
+            SampleTasks.withAllPriorities(),
+        ],
+
+        // idea: heading: only show the heading if it differs from the filename (for when I want to group by file and then heading)
+        // idea: folder: show only the closest folder name, not the whole tree
+        // idea: folder: show stripping out the folder containing the query file - may need to escape forward slashes if using regular expression
+    ];
+
+    it.each(testData)('%s results', (_: string, groups: string[][], tasks: Task[]) => {
+        verifyFunctionFieldGrouperSamplesOnTasks(groups, tasks);
+    });
+
+    it.each(testData)('%s docs', (_: string, groups: string[][], _tasks: Task[]) => {
+        verifyFunctionFieldGrouperSamplesForDocs(groups);
+    });
+});
diff --git a/tests/Query/Filter/ReferenceDocs/FilterReference/VerifyFunctionFieldSamples.ts b/tests/Query/Filter/ReferenceDocs/FilterReference/VerifyFunctionFieldSamples.ts
new file mode 100644
index 00000000..6694558a
--- /dev/null
+++ b/tests/Query/Filter/ReferenceDocs/FilterReference/VerifyFunctionFieldSamples.ts
@@ -0,0 +1,38 @@
+import { verifyAll } from 'approvals/lib/Providers/Jest/JestApprovals';
+import { FunctionField } from '../../../../../src/Query/Filter/FunctionField';
+import type { Task } from '../../../../../src/Task';
+import { groupHeadingsForTask } from '../../../../CustomMatchers/CustomMatchersForGrouping';
+import { verifyMarkdownForDocs } from '../../../../TestingTools/VerifyMarkdownTable';
+
+export function verifyFunctionFieldGrouperSamplesOnTasks(customGroups: string[][], tasks: Task[]) {
+    verifyAll('Results of custom groupers', customGroups, (group) => {
+        const instruction = group[0];
+        const comment = group[1];
+        const grouper = new FunctionField().createGrouperFromLine(instruction);
+        const headings = groupHeadingsForTask(grouper!, tasks);
+        return `
+${instruction}
+${comment}
+=>
+${headings.join('\n')}
+====================================================================================
+`;
+    });
+}
+
+export function verifyFunctionFieldGrouperSamplesForDocs(customGroups: string[][]) {
+    let markdown = '';
+    for (const group of customGroups) {
+        const instruction = group[0];
+        const comment = group[1];
+        markdown += `
+~~~text
+${instruction}
+~~~
+
+- ${comment}.
+
+`;
+    }
+    verifyMarkdownForDocs(markdown);
+}
diff --git a/tests/Scripting/Expression.test.Expression_result.approved.md b/tests/Scripting/Expression.test.Expression_result.approved.md
new file mode 100644
index 00000000..85989749
--- /dev/null
+++ b/tests/Scripting/Expression.test.Expression_result.approved.md
@@ -0,0 +1,26 @@
+<!-- placeholder to force blank line before included text -->
+
+~~~text
+'hello' => 'hello'
+"hello" => 'hello'
+"" => ''
+[] => []
+"" || "No value" => 'No value'
+false => false
+true => true
+1 => 1
+0 => 0
+0 || "No value" => 'No value'
+1.0765456 => 1.0765456
+6 * 7 => 42
+["heading1", "heading2"] => ['heading1', 'heading2']
+[1, 2] => [1, 2]
+null => null
+null || "No value" => 'No value'
+undefined => undefined
+undefined || "No value" => 'No value'
+"I _am_ not _italic_".replaceAll("_", "\\_") => 'I \_am\_ not \_italic\_'
+~~~
+
+
+<!-- placeholder to force blank line after included text -->
diff --git a/tests/Scripting/Expression.test.ts b/tests/Scripting/Expression.test.ts
new file mode 100644
index 00000000..3d9006af
--- /dev/null
+++ b/tests/Scripting/Expression.test.ts
@@ -0,0 +1,47 @@
+/**
+ * @jest-environment jsdom
+ */
+import moment from 'moment';
+
+import { evaluateExpression } from '../../src/Scripting/Expression';
+import { TaskBuilder } from '../TestingTools/TaskBuilder';
+import { verifyMarkdownForDocs } from '../TestingTools/VerifyMarkdownTable';
+import { formatToRepresentType } from './ScriptingTestHelpers';
+
+window.moment = moment;
+
+describe('Expression', () => {
+    it('result', () => {
+        const task = TaskBuilder.createFullyPopulatedTask();
+        const expressions = [
+            "'hello'",
+            '"hello"',
+            '""',
+            '[]',
+            '"" || "No value"',
+            'false',
+            'true',
+            '1',
+            '0',
+            '0 || "No value"',
+            '1.0765456',
+            '6 * 7',
+            '["heading1", "heading2"]',
+            '[1, 2]',
+            'null',
+            'null || "No value"',
+            'undefined',
+            'undefined || "No value"',
+            // Should allow manual escaping of markdown
+            String.raw`"I _am_ not _italic_".replaceAll("_", "\\_")`,
+        ];
+
+        let markdown = '~~~text\n';
+        for (const expression of expressions) {
+            const result = evaluateExpression(task, expression);
+            markdown += `${expression} => ${formatToRepresentType(result)}\n`;
+        }
+        markdown += '~~~\n';
+        verifyMarkdownForDocs(markdown);
+    });
+});
diff --git a/tests/Scripting/ScriptingTestHelpers.ts b/tests/Scripting/ScriptingTestHelpers.ts
new file mode 100644
index 00000000..c1b6a1e4
--- /dev/null
+++ b/tests/Scripting/ScriptingTestHelpers.ts
@@ -0,0 +1,43 @@
+import moment from 'moment';
+import { TaskRegularExpressions } from '../../src/Task';
+
+export function formatToRepresentType(x: any): string {
+    if (typeof x === 'string') {
+        return "'" + x + "'";
+    }
+
+    if (moment.isMoment(x)) {
+        return `moment('${x.format(TaskRegularExpressions.dateTimeFormat)}')`;
+    }
+
+    if (Array.isArray(x)) {
+        return '[' + x.map((v) => formatToRepresentType(v)).join(', ') + ']';
+    }
+    // TODO Round numbers
+    // TODO Format string arrays - can I use 'toString()'?
+    // TODO Fix display of 2 spaces as `'  '` - which appears as a single space. <pre></pre> gets the spacing OK, but line is too tall
+    return x;
+}
+
+export function addBackticks(x: any) {
+    return '`' + x + '`';
+}
+
+export function determineExpressionType(value: any) {
+    if (value === null) {
+        return 'null';
+    }
+
+    if (moment.isMoment(value)) {
+        return 'Moment';
+    }
+
+    if (Array.isArray(value)) {
+        if (value.length > 0) {
+            return `${typeof value[0]}[]`;
+        } else {
+            return 'any[]';
+        }
+    }
+    return typeof value;
+}
diff --git a/tests/Scripting/TaskProperties.test.task_dates.approved.md b/tests/Scripting/TaskProperties.test.task_dates.approved.md
new file mode 100644
index 00000000..f28b5ff5
--- /dev/null
+++ b/tests/Scripting/TaskProperties.test.task_dates.approved.md
@@ -0,0 +1,13 @@
+<!-- placeholder to force blank line before included text -->
+
+| 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` |
+
+
+<!-- placeholder to force blank line after included text -->
diff --git a/tests/Scripting/TaskProperties.test.task_file_properties.approved.md b/tests/Scripting/TaskProperties.test.task_file_properties.approved.md
new file mode 100644
index 00000000..9462b8f6
--- /dev/null
+++ b/tests/Scripting/TaskProperties.test.task_file_properties.approved.md
@@ -0,0 +1,8 @@
+<!-- placeholder to force blank line before included text -->
+
+| Field | Type 1 | Example 1 | Type 2 | Example 2 |
+| ----- | ----- | ----- | ----- | ----- |
+| `task.precedingHeader` | `string` | `'My Header'` | `null` | `null` |
+
+
+<!-- placeholder to force blank line after included text -->
diff --git a/tests/Scripting/TaskProperties.test.task_other_fields.approved.md b/tests/Scripting/TaskProperties.test.task_other_fields.approved.md
new file mode 100644
index 00000000..99f21b89
--- /dev/null
+++ b/tests/Scripting/TaskProperties.test.task_other_fields.approved.md
@@ -0,0 +1,10 @@
+<!-- placeholder to force blank line before included text -->
+
+| Field | Type 1 | Example 1 | Type 2 | Example 2 |
+| ----- | ----- | ----- | ----- | ----- |
+| `task.description` | `string` | `'Do exercises #todo #health'` | `string` | `'minimal task'` |
+| `task.urgency` | `number` | `3.3000000000000007` | `number` | `1.9500000000000002` |
+| `task.tags` | `string[]` | `['#todo', '#health']` | `any[]` | `[]` |
+
+
+<!-- placeholder to force blank line after included text -->
diff --git a/tests/Scripting/TaskProperties.test.task_status.approved.md b/tests/Scripting/TaskProperties.test.task_status.approved.md
new file mode 100644
index 00000000..77c541d9
--- /dev/null
+++ b/tests/Scripting/TaskProperties.test.task_status.approved.md
@@ -0,0 +1,11 @@
+<!-- placeholder to force blank line before included text -->
+
+| Field | Type 1 | Example 1 | Type 2 | Example 2 |
+| ----- | ----- | ----- | ----- | ----- |
+| `task.status.name` | `string` | `'Todo'` | `string` | `'In Progress'` |
+| `task.status.type` | `string` | `'TODO'` | `string` | `'IN_PROGRESS'` |
+| `task.status.symbol` | `string` | `' '` | `string` | `'/'` |
+| `task.status.nextStatusSymbol` | `string` | `'x'` | `string` | `'x'` |
+
+
+<!-- placeholder to force blank line after included text -->
diff --git a/tests/Scripting/TaskProperties.test.ts b/tests/Scripting/TaskProperties.test.ts
new file mode 100644
index 00000000..e1a03731
--- /dev/null
+++ b/tests/Scripting/TaskProperties.test.ts
@@ -0,0 +1,81 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import moment from 'moment';
+import { evaluateExpression } from '../../src/Scripting/Expression';
+import { Status } from '../../src/Status';
+
+import { TaskBuilder } from '../TestingTools/TaskBuilder';
+import { MarkdownTable } from '../TestingTools/VerifyMarkdownTable';
+import { addBackticks, determineExpressionType, formatToRepresentType } from './ScriptingTestHelpers';
+
+window.moment = moment;
+
+// TODO Show a task in a callout, or an ordered list
+
+describe('task', () => {
+    function verifyFieldDataForReferenceDocs(fields: string[]) {
+        const markdownTable = new MarkdownTable(['Field', 'Type 1', 'Example 1', 'Type 2', 'Example 2']);
+        const task1 = TaskBuilder.createFullyPopulatedTask();
+        const task2 = new TaskBuilder().description('minimal task').status(Status.makeInProgress()).build();
+        for (const field of fields) {
+            const value1 = evaluateExpression(task1, field);
+            const value2 = evaluateExpression(task2, field);
+            const cells = [
+                addBackticks(field),
+                addBackticks(determineExpressionType(value1)),
+                addBackticks(formatToRepresentType(value1)),
+                addBackticks(determineExpressionType(value2)),
+                addBackticks(formatToRepresentType(value2)),
+            ];
+            markdownTable.addRow(cells);
+        }
+        markdownTable.verifyForDocs();
+    }
+
+    it('status', () => {
+        verifyFieldDataForReferenceDocs([
+            'task.status.name',
+            'task.status.type',
+            'task.status.symbol',
+            'task.status.nextStatusSymbol',
+        ]);
+    });
+
+    it('dates', () => {
+        verifyFieldDataForReferenceDocs([
+            'task.created',
+            'task.start',
+            'task.scheduled',
+            'task.due',
+            'task.done',
+            'task.happens',
+        ]);
+    });
+
+    it('other fields', () => {
+        // TODO Recurrence
+        verifyFieldDataForReferenceDocs([
+            'task.description',
+            // 'task.priority', // I think it will be more useful for task.priority to refer to the name. Currently, it gives the number. Needs more thought.
+            'task.urgency',
+            'task.tags',
+            // 'task.indentation', // Cannot just use length to determine if sub-task, as it many be '> ' due to being in a sub-task
+            // 'task.listMarker', // Not a priority to release
+            // 'task.blockLink', // Release support for grouping by task.blockLink, after removing the leading space and maybe the carat
+            // 'task.originalMarkdown', // Not a priority to release
+        ]);
+    });
+
+    it('file properties', () => {
+        // TODO Replace this with values that can be published
+        // TODO Create task.file
+        verifyFieldDataForReferenceDocs([
+            // 'task.path',
+            // 'task.filename',
+            // 'task.lineNumber', // this name is misleading - in some circumstances the line number is not updated after user edits
+            'task.precedingHeader',
+        ]);
+    });
+});
diff --git a/tests/Task.test.ts b/tests/Task.test.ts
index 7dc202b2..ea13f73f 100644
--- a/tests/Task.test.ts
+++ b/tests/Task.test.ts
@@ -410,6 +410,54 @@ 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!);
+    });
+
+    it('should provide access to happens and happensDates', () => {
+        // Fields that are used by happens:
+        const dueDate = '2023-06-19';
+        const scheduledDate = '2023-06-20';
+        const startDate = '2023-06-21';
+        const task = new TaskBuilder().dueDate(dueDate).scheduledDate(scheduledDate).startDate(startDate).build();
+        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
+    });
+
+    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();
+    });
+
+    it('should provide access to recurring-related properties', () => {
+        const non_recurring = '- [ ] non-recurring task';
+        const recurring = '- [ ] recurring 🔁 every day 📅 2022-06-17';
+        // Invalid recurrence rules are discarded, and treated as non-recurring
+        const invalid = '- [ ] recurring 🔁 invalid rule 📅 2022-06-17';
+
+        // Test Task.recurring
+        expect(fromLine({ line: non_recurring }).recurring).toEqual(false);
+        expect(fromLine({ line: recurring }).recurring).toEqual(true);
+        expect(fromLine({ line: invalid }).recurring).toEqual(false);
+
+        // TODO Rename recurrence field, so that we can make Task.recurrence return the text directly.
+        //      And then release scripting access to task.recurring and task.recurrence
+        expect(fromLine({ line: non_recurring }).recurrence).toEqual(null);
+        expect(fromLine({ line: recurring }).recurrence!.toText()).toEqual('every day');
+        expect(fromLine({ line: invalid }).recurrence).toEqual(null);
+    });
+});
+
 describe('backlinks', () => {
     function shouldGiveLinkText(
         path: string,
@@ -497,6 +545,7 @@ describe('to string', () => {
 
         // Assert
         expect(task).not.toBeNull();
+        expect(task.indentation).toEqual('> > > ');
         expect(task.toFileLineString()).toStrictEqual(line);
     });
 
diff --git a/tests/TestHelpers.ts b/tests/TestHelpers.ts
index 5316e25c..6aca895b 100644
--- a/tests/TestHelpers.ts
+++ b/tests/TestHelpers.ts
@@ -41,6 +41,23 @@ export function createTasksFromMarkdown(tasksAsMarkdown: string, path: string, p
 const representativeDates = ['2023-05-30', '2023-05-31', '2023-06-01', '2023-02-32', null];
 
 export class SampleTasks {
+    public static withRepresentativeTags(): Task[] {
+        const sampleTags = [
+            [],
+            ['#tag'],
+            ['#tag/subtag'],
+            ['#tag/subtag/sub-sub-tag'],
+            ['#multiple-tags1', '#multiple-tags2'],
+            ['#project/project1'],
+            ['#context/home'],
+            ['#context/work'],
+        ];
+
+        return sampleTags.map((tags) => {
+            return new TaskBuilder().tags(tags).build();
+        });
+    }
+
     public static withAllRecurrences(): Task[] {
         const recurrenceRules = [
             // Months
@@ -198,4 +215,21 @@ export class SampleTasks {
         });
         return tasks;
     }
+
+    public static withAllRepresentativeDescriptions(): Task[] {
+        const descriptions = [
+            'short description',
+            'long description Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce quam ipsum, consectetur ut dolor nec, fringilla lobortis mi. Vestibulum gravida tincidunt urna nec ornare. Cras sit amet sagittis sapien, vitae mattis velit. Vestibulum sem tortor, blandit at ultrices eget, ultrices eget odio. Donec efficitur purus massa, vel molestie turpis tincidunt id. ',
+        ];
+        return descriptions.map((description) => {
+            return new TaskBuilder().description(description).build();
+        });
+    }
+
+    public static withAllRepresentativeBlockLinks(): Task[] {
+        const descriptions = ['', ' ^ca47c7', ' ^fromseparatefile'];
+        return descriptions.map((blockLink) => {
+            return new TaskBuilder().blockLink(blockLink).build();
+        });
+    }
 }
diff --git a/tests/TestingTools/ApprovalTestHelpers.ts b/tests/TestingTools/ApprovalTestHelpers.ts
index 57d9604b..7509284f 100644
--- a/tests/TestingTools/ApprovalTestHelpers.ts
+++ b/tests/TestingTools/ApprovalTestHelpers.ts
@@ -3,6 +3,28 @@ import { verify } from 'approvals/lib/Providers/Jest/JestApprovals';
 import { Query } from '../../src/Query/Query';
 import { explainResults } from '../../src/lib/QueryRendererHelper';
 
+export function printIteration<T1>(func: <T1>(t1: T1) => any, params1: T1[]): string {
+    const EMPTY_ENTRY = {};
+    let text = '';
+    for (const p1 of params1) {
+        let output;
+        try {
+            output = func(p1);
+        } catch (e) {
+            output = `${e}`;
+        }
+        const parameters = [p1].filter((p) => p !== EMPTY_ENTRY);
+
+        text += `${parameters} => ${output}\n`;
+    }
+    return text;
+}
+
+export function verifyAll<T1>(func: (i: T1) => any, params1: T1[]) {
+    // @ts-ignore
+    verify(printIteration((t1) => func(t1), params1));
+}
+
 /**
  * Save an instructions block to disc, so that it can be embedded in
  * to documentation, using a 'snippet' line.