Repository: obsidian-tasks-group/obsidian-tasks
Pull request #1241 (2022-10-20 13:50:26)

feat: Add 'explain' instruction, to visualise tasks block filters
## Description

**This is a draft PR**. I will be sharing it in Discord to get feedback before release

**How to test this new feature**:

1. Download [Tasks-Demo-VerifyCommit-Build1474-Run1.zip](https://github.com/obsidian-tasks-group/obsidian-tasks/suites/8876106809/artifacts/405524462)
1. Expand the file
1. Open the folder in Obsidian
    - Use the [Vault switcher](https://github.com/obsidian-tasks-group/obsidian-tasks/suites/8876106809/artifacts/405524462)
    - Choose “Open folder as vault” and select a folder on your device
    - Open the `Filters` folder
    - Open the file `Explain Filters` and view it side-by-side, in Source view and either Reading or Live Preview.
    - Edit `Explain Filters` and try out other searches.

Any feedback on the feature would be greatly appreciated - please add comments and feedback below... Thanks in advance!

---

### Overview

This PR adds a new  `explain` instruction. When given, `tasks` blocks display the logic of their filters, in a `<PRE>` block.

Benefits:

- Dates in filters are expanded, to show the actual date used in the search
- Boolean query logic is clearer

The CSS class name  `plugin-tasks-query-explanation` is used.

### Styling

### Issues, and remaining work

- [ ] ~~If more than 1 `AND` or `OR` on a line, the explained query is heavily nested, and a bit hard to read. This is the behaviour of a  3rd-party library that Tasks is using.~~
    - Decided not to do anything about this for now. The feature is still useful with the nested output, and feedback suggests that it is useful that it explains the current implementation
- [x] `starts` instruction does not explain that it matches tasks with no start date.
- [x] There probably needs to be special wording when no filters supplied.
    - I considered special-case code for when 1 filter supplied, but decided it looked better when consistent with the display for 2 or more filters
- [ ] Would  like to represent any global filter
- [ ] Would  like to represent any limit
- [ ] Would  like to represent the Tasks version
- [x] Test in dark theme - darkblue colour in sample snippet likely unhelpful
- [x] Try to stop long lines wrapping - instead add horizontal scroll bar
- [ ] Add a key at the end, showing the various emoji
- [ ] Documentation is needed - especially document things in feedback from `jloh`

### Examples

#### Dates in filters are expanded

For example, when viewed in Reading or Live Preview modes:

````
```tasks
scheduled after 2 years ago
due before tomorrow
explain
```
````

the results begin with the following, on `2022-10-21`:

```text
Explanation of query:

All of:
  scheduled after 2 years ago =>
    scheduled date is after 2020-10-20 (Tuesday)

  due before tomorrow =>
    due date is before 2022-10-21 (Friday)
```

#### Boolean combinations are displayed

For example, when viewed in Reading or Live Preview modes:

````
```tasks
explain
not done
(due before tomorrow) AND (is recurring)
```
````

the results begin with the following, on `2022-10-21`:

```text
Explanation of query:

All of:
  not done

  (due before tomorrow) AND (is recurring) =>
    All of:
      due date is before 2022-10-21 (Friday)
      is recurring
```




## Motivation and Context

Main aims:

- Help users to understand and check date-based filters
- Help in user support. Up until now, to test date filters such as `due after tomorrow`, I (and users) have had to:
    - add a bunch of test tasks with different dates
    - view a tasks block
    - try to reason about which tasks where displayed, to then calculate the date actually used.

## How has this been tested?

- Added tests
- Ran existing tests
- Tested manually in test vault

## Screenshots (if appropriate)

![image](https://user-images.githubusercontent.com/4840096/196966872-96224e1b-b221-401b-8dfa-0edd2dd687cc.png)

![image](https://user-images.githubusercontent.com/4840096/196966971-861eb256-c994-43f2-819f-e85b638dd0a8.png)

![image](https://user-images.githubusercontent.com/4840096/196967083-f294c091-c462-426e-804c-0cdef3bd2e08.png)

## 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)
- [ ] **Documentation** (prefix: `docs` - improvements to any documentation content)
- [x] **Sample vault** (prefix: `vault` - improvements to the [Tasks-Demo sample vault](https://github.com/obsidian-tasks-group/obsidian-tasks/tree/main/resources/sample_vaults/Tasks-Demo))

Internal changes:

- [x] **Tests** (prefix: `test` - additions and improvements to unit tests and the smoke tests)

## Checklist

<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

- [x] My code follows the code style of this project and passes `yarn run lint`.
- [x] My change requires a change to the documentation.
- [ ] I have [updated the documentation](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md#updating-documentation) accordingly.
- [x] My change has adequate [Unit Test coverage](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md#maintaining-the-tests).

## Terms

<!--
By submitting this pull request, you must agree to follow our
[contributing guide](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md) 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://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md)
- [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)


claremacrae: @schemar This is basically an FYI, although I would of course appreciate any feedback you may have...

It's still in draft, and in the description above is a list of things that I know I need to do, or would like to do.

It should reduce the support overhead quite a lot, as well as helping users understand how date-based and Boolean filters work.
jloh: I've taken some of the more "complex" queries from some of my vaults (ones that use DataviewJS to get dates and stuff) and it explains them really well. It can get confusing on smaller screens, eg when the screen is split, and lines wrap.

<img width="615" alt="image" src="https://user-images.githubusercontent.com/3689532/197317335-a22f595d-3418-4dd5-8c66-9a18b5444c6f.png">

vs

<img width="811" alt="image" src="https://user-images.githubusercontent.com/3689532/197317343-1da7a621-5b55-49ef-b0cc-d893b3fd584e.png">

I found the non-wraped version easier to understand the hierarchy and see which ones are grouped. One thought is you could add ascii style indenting like the `tree` command, but I'm unsure how technically difficult this would be and whether it could make the interface way to busy?

I'm thinking something like this:

```
All of:
|--  not done
|
|--  (((happens before 2022-10-23) AND (happens after 2022-10-22)) OR (filename includes 2022-10-22.md)) =>
    At least one of:
    |--  All of:
    |    |-- due, start or scheduled date is before 2022-10-23 (Sunday)
    |    |-- due, start or scheduled date is after 2022-10-22 (Saturday)
    |--  filename includes 2022-10-22.md
```

Very poor example but hopefully you get the idea? I admit it mightn't help in the case of smaller screens, so could be a moot point.

The other thought is on initially looking at the result I found the week day after the date confusing. Its currently Saturday where I am and even though one of the dates for a task is several months in the future the week day happened to be a Sunday and I initially thought for some reason it was tomorrow. This could be more a me problem but I wonder if it could make skimming it easier if the date was something like `Saturday 22nd Oct` instead? Just to make it more clear what the date is. I think the year its attached to is pretty explicit.

Another feature (Sorry!) I had is it would be amazing if the tool had an option to display a legend below the display, something like this:

<img width="468" alt="image" src="https://user-images.githubusercontent.com/3689532/197317951-67264fc1-6707-46e7-9a5d-9dd8d76f0f6b.png">

Could be `Due: 📅 <date>` or something to show it more specifically, I just thought a visual guide to match the words in the examples to the emojis used in the task lines could be helpful. I often forget what the emoji for Start is for example and as I don't use it much 😄 

I really liked how the example is right above the results, it makes debugging queries live extremely helpful.

I didn't quite understand the display of the nested boolean query however. If its laid out how it is computed internally then ignore me but personally reading the query this is how I imagine it is parsed:

```
Explanation of query:

All of:
  (description includes 1) OR (description includes 2) AND (description includes 3) AND (description includes 4) AND (description includes 5) AND (description includes 6) =>
    At least one of:
      description includes 1
      All of:
        description includes 2 AND 3 AND 4 AND 5 AND 6
```

To me the nested result of the boolean query makes me think of it being written like this (indented to make it easier to understand):

```
explain
(description includes 1) OR (
  (description includes 2) AND (
    (description includes 3) AND (
      (description includes 4) AND (
        (description includes 5) AND (
          (description includes 6)
        )
      )
    )
  )
)
limit 1
```

In turn this actually renders like this interestingly:

```
Explanation of query:

All of:
  (description includes 1) OR ((description includes 2) AND ((description includes 3) AND ((description includes 4) AND ((description includes 5) AND ((description includes 6)))))) =>
    At least one of:
      description includes 1
      All of:
        description includes 2
        All of:
          description includes 3
          All of:
            description includes 4
            All of:
              description includes 5
              description includes 6
```

Again, this is perhaps just how I'm interrupting it so please ignore if it doesn't make sense 😄 

Hopefully this feedback is helpful. I tested on a MacBook running MacOS 12.6. I have a Windows and Linux PC if thats helpful as well though!

Let me know what else I can do to assist.
claremacrae: Wow, thank you @jloh thank you for amazing feedback. This will be very useful both in improving the behaviour and in writing the docs!

I'll reply to your comments in detail later when I'm on a laptop.
claremacrae: @jloh I've read your comments several times, and am pondering what to do.

### Presentation - and Boolean nesting

The horrible nested layout of Boolean queries is because of the feature being implemented using a 3rd-party library that breaks the expression down in to sets of calculations which each take 1 or 2 values.
So when one writes 
`(filter1) AND (filter2) AND (filter3)`
it gets parsed down to something like:
`(filter1) AND ((filter2) AND (filter3))`
In other words, two ANDs, each of which takes 2 values.

This is why the output from multiple boolean combinations is so nested...
I was considering adding some code to try and rework the tree of filters to make a run of ANDs or Ors easier to read.
BUT that's going to take some thinking, and need a lot of careful testing to make sure that I don't get the logic wrong and mislead people.

So I initially thought that maybe this first version was useful enough to go ahead and release it with ugly nested Booleans as they are now - and maybe refine the presentation later on.

After your feedback I'm not so sure whether to release it yet.

A possible interim solution might be to see if I can add CSS to prevent the explanation lines from wrapping - so that one would need to scroll horizontally left and right. Which might make it all easier to read - slightly.

### Adding a key

that's a brilliant idea. Thank you.

### happens

After you tested it, I improved the output so that it says something like `due, scheduled or start after 2022-12-17` - which makes the logic in your screenshots a bit less confusing. I first thought some of your happens combinations could not give any matches until I realised they could match values in different fields.

### Timing of the release

I am keen to release the feature because I think that explanation of how dates are calculated really helps people who are stuck with their queries not finding what they expected.

I suspect some of those will be new to the plugin and have relatively simple queries, and would still benefit from the explanations.

I think for now I will probably implement the things in the checkbox in the description of this PR - and add the key, and try to stop long lines wrapping.
And then document the things you found confusing, with a note to say that they will be improved in a future release.

Please do say if you think the ugly Boolean nesting should be fixed first...
jloh: @claremacrae I think this feature will be fantastic as-is without the Boolean changes. Can always modify things further down the line if user feedback shows confusion. IMO its important that it shows how it all gets evaluated internally so if that’s what it does now (or close enough) then that’s how it should be 👍 

Let me know if it would be helpful for me to test again 🙏 
claremacrae: > The other thought is on initially looking at the result I found the week day after the date confusing. Its currently Saturday where I am and even though one of the dates for a task is several months in the future the week day happened to be a Sunday and I initially thought for some reason it was tomorrow. This could be more a me problem but I wonder if it could make skimming it easier if the date was something like Saturday 22nd Oct instead? Just to make it more clear what the date is. I think the year its attached to is pretty explicit.

@jloh This got me thinking. There have been some users support queries where people misread the order of day and month in the dates... So to remove ambiguity I've changed the format of the dates displayed:

```text
Before: 2012-01-23 (Monday)
After:  2012-01-23 (Monday 23rd January 2012)
```

claremacrae: I am starting to think about user docs for this, and in order to be able to include sample explanations in the docs, I really want to use the mdsnippets and Approval Tests changes recently merged to the main branch.

So I'm going to merge this PR now, and finish off the remaining actions on a new branch.

Patch:
diff --git a/resources/sample_vaults/Tasks-Demo/.obsidian/snippets/tasks-plugin-explain-text-blue.css b/resources/sample_vaults/Tasks-Demo/.obsidian/snippets/tasks-plugin-explain-text-blue.css
new file mode 100644
index 00000000..b87495c3
--- /dev/null
+++ b/resources/sample_vaults/Tasks-Demo/.obsidian/snippets/tasks-plugin-explain-text-blue.css
@@ -0,0 +1,3 @@
+.plugin-tasks-query-explanation{
+    color: var(--color-blue);
+}
diff --git a/resources/sample_vaults/Tasks-Demo/Filters/Explain Filters.md b/resources/sample_vaults/Tasks-Demo/Filters/Explain Filters.md
new file mode 100644
index 00000000..418fe4dc
--- /dev/null
+++ b/resources/sample_vaults/Tasks-Demo/Filters/Explain Filters.md
@@ -0,0 +1,29 @@
+# Explain Filters
+
+The `explain` instruction tells a Tasks block to display details of its filters.
+
+## Date-dependent values are expanded
+
+```tasks
+scheduled after 2 years ago
+due before tomorrow
+explain
+limit 1
+```
+
+## Boolean combinations are displayed
+
+```tasks
+explain
+not done
+(due before tomorrow) AND (is recurring)
+limit 1
+```
+
+## Nested Boolean logic
+
+```tasks
+explain
+(description includes 1) AND (description includes 2) AND (description includes 3) AND (description includes 4)
+limit 1
+```
diff --git a/src/IQuery.ts b/src/IQuery.ts
index 24dc134a..ba785164 100644
--- a/src/IQuery.ts
+++ b/src/IQuery.ts
@@ -60,4 +60,11 @@ export interface IQuery {
      * @memberof Query
      */
     applyQueryToTasks: (tasks: Task[]) => TaskGroups;
+
+    /**
+     * Return a text representation of the query.
+     *
+     * This is currently displayed as a <pre> block, retaining indentation.
+     */
+    explainQuery: () => string;
 }
diff --git a/src/LayoutOptions.ts b/src/LayoutOptions.ts
index cf0660e5..3cf5faa7 100644
--- a/src/LayoutOptions.ts
+++ b/src/LayoutOptions.ts
@@ -10,4 +10,5 @@ export class LayoutOptions {
     hideEditButton: boolean = false;
     hideUrgency: boolean = true;
     shortMode: boolean = false;
+    explainQuery: boolean = false;
 }
diff --git a/src/Query/Explain/Explanation.ts b/src/Query/Explain/Explanation.ts
new file mode 100644
index 00000000..69d2424f
--- /dev/null
+++ b/src/Query/Explain/Explanation.ts
@@ -0,0 +1,72 @@
+/**
+ * An Explanation object stores a text description of a Query instruction, or a component of one.
+ *
+ * It supports Boolean combinations via the {@link children} field.
+ *
+ * Initially, the {@link description} will simply restate the instruction filter.
+ * Later, more human-readable descriptions will be generated.
+ */
+export class Explanation {
+    public description: string;
+    public children: Explanation[];
+
+    constructor(description: string, children: Explanation[] = []) {
+        this.description = description;
+        this.children = children;
+    }
+
+    /**
+     * Create an Explanation object representing Boolean AND
+     * @param children
+     */
+    public static booleanAnd(children: Explanation[]) {
+        return new Explanation('All of', children);
+    }
+
+    /**
+     * Create an Explanation object representing Boolean OR
+     * @param children
+     */
+    public static booleanOr(children: Explanation[]) {
+        return new Explanation('At least one of', children);
+    }
+
+    /**
+     * Create an Explanation object representing Boolean NOT
+     * @param children
+     */
+    public static booleanNot(children: Explanation[]) {
+        return new Explanation('None of', children);
+    }
+
+    /**
+     * Create an Explanation object representing Boolean XOR
+     * @param children
+     */
+    public static booleanXor(children: Explanation[]) {
+        return new Explanation('Exactly one of', children);
+    }
+
+    /**
+     * Create a string representation of the Explanation.
+     *
+     * Note that it will not have a final end-of-line character at the end.
+     *
+     * @param currentIndentation - This is an implementation detail. Users can ignore it.
+     */
+    public asString(currentIndentation: string = '') {
+        let result = currentIndentation + this.description;
+        if (this.children.length == 0) {
+            // No children, so just return
+            return result;
+        }
+
+        // We have children, so concatenate them together
+        result += ':';
+        const newIndentation = currentIndentation + '  ';
+        for (let i = 0; i < this.children.length; i++) {
+            result += `\n${this.children[i].asString(newIndentation)}`;
+        }
+        return result;
+    }
+}
diff --git a/src/Query/Filter/BooleanField.ts b/src/Query/Filter/BooleanField.ts
index 222705e5..21f56b74 100644
--- a/src/Query/Filter/BooleanField.ts
+++ b/src/Query/Filter/BooleanField.ts
@@ -3,9 +3,10 @@ import type { PostfixExpression } from 'boon-js';
 
 import { parseFilter } from '../FilterParser';
 import type { Task } from '../../Task';
+import { Explanation } from '../Explain/Explanation';
 import { Field } from './Field';
 import { FilterOrErrorMessage } from './Filter';
-import type { Filter } from './Filter';
+import { Filter } from './Filter';
 
 /**
  * BooleanField is a 'container' field type that parses a high-level filtering query of
@@ -92,10 +93,12 @@ export class BooleanField extends Field {
                     }
                 }
             }
-            // Return the filter function that can run the complete query
-            result.filterFunction = (task: Task) => {
+            // Return the filter with filter function that can run the complete query
+            const filterFunction = (task: Task) => {
                 return this.filterTaskWithParsedQuery(task, postfixExpression);
             };
+            const explanation = this.constructExplanation(postfixExpression);
+            result.filter = new Filter(line, filterFunction, explanation);
             return result;
         } catch (error) {
             const message = error instanceof Error ? error.message : 'unknown error type';
@@ -164,4 +167,47 @@ export class BooleanField extends Field {
         // Eventually the result of the expression for this Task is the only item left in the boolean stack
         return toBool(booleanStack[0]);
     }
+
+    /**
+     * Construct an {@link Explanation} representing the complete Boolean instruction currently being analysed.
+     *
+     * @param postfixExpression
+     */
+    private constructExplanation(postfixExpression: PostfixExpression): Explanation {
+        // For an explanation of the code, see the JSdoc and comments of filterTaskWithParsedQuery()
+        const booleanStack: Explanation[] = [];
+        // TODO Review comments
+        for (const token of postfixExpression) {
+            if (token.name === 'IDENTIFIER') {
+                if (token.value == null) throw Error('null token value'); // This should not happen
+                const filter = this.subFields[token.value.trim()];
+                booleanStack.push(filter.explanation);
+            } else if (token.name === 'OPERATOR') {
+                // To evaluate an operator we need to pop the required number of items from the boolean stack,
+                // do the logical evaluation and push back the result
+                if (token.value === 'NOT') {
+                    const arg1 = booleanStack.pop();
+                    booleanStack.push(Explanation.booleanNot([arg1!]));
+                } else if (token.value === 'OR') {
+                    const arg2 = booleanStack.pop();
+                    const arg1 = booleanStack.pop();
+                    booleanStack.push(Explanation.booleanOr([arg1!, arg2!]));
+                } else if (token.value === 'AND') {
+                    const arg2 = booleanStack.pop();
+                    const arg1 = booleanStack.pop();
+                    booleanStack.push(Explanation.booleanAnd([arg1!, arg2!]));
+                } else if (token.value === 'XOR') {
+                    const arg2 = booleanStack.pop();
+                    const arg1 = booleanStack.pop();
+                    booleanStack.push(Explanation.booleanXor([arg1!, arg2!]));
+                } else {
+                    throw Error('Unsupported operator: ' + token.value);
+                }
+            } else {
+                throw Error('Unsupported token type: ' + token);
+            }
+        }
+        // Eventually the Explanation is the only item left in the boolean stack
+        return booleanStack[0];
+    }
 }
diff --git a/src/Query/Filter/DateField.ts b/src/Query/Filter/DateField.ts
index ef6e7fa6..ac694524 100644
--- a/src/Query/Filter/DateField.ts
+++ b/src/Query/Filter/DateField.ts
@@ -1,8 +1,9 @@
 import type { Moment } from 'moment';
 import type { Task } from '../../Task';
 import { DateParser } from '../DateParser';
+import { Explanation } from '../Explain/Explanation';
 import { Field } from './Field';
-import { FilterOrErrorMessage } from './Filter';
+import { Filter, FilterOrErrorMessage } from './Filter';
 import { FilterInstructions } from './FilterInstructions';
 
 /**
@@ -41,27 +42,39 @@ export abstract class DateField extends Field {
         const result = new FilterOrErrorMessage(line);
 
         const match = Field.getMatch(this.filterRegExp(), line);
+        let filterFunction;
         if (match !== null) {
             const filterDate = DateParser.parseDate(match[2]);
             if (!filterDate.isValid()) {
                 result.error = 'do not understand ' + this.fieldName() + ' date';
             } else {
+                let relative;
                 if (match[1] === 'before') {
-                    result.filterFunction = (task: Task) => {
+                    filterFunction = (task: Task) => {
                         const date = this.date(task);
                         return date ? date.isBefore(filterDate) : this.filterResultIfFieldMissing();
                     };
+                    relative = ' ' + match[1];
                 } else if (match[1] === 'after') {
-                    result.filterFunction = (task: Task) => {
+                    filterFunction = (task: Task) => {
                         const date = this.date(task);
                         return date ? date.isAfter(filterDate) : this.filterResultIfFieldMissing();
                     };
+                    relative = ' ' + match[1];
                 } else {
-                    result.filterFunction = (task: Task) => {
+                    filterFunction = (task: Task) => {
                         const date = this.date(task);
                         return date ? date.isSame(filterDate) : this.filterResultIfFieldMissing();
                     };
+                    relative = ' on';
                 }
+                const explanation = DateField.getExplanationString(
+                    this.fieldName(),
+                    relative,
+                    this.filterResultIfFieldMissing(),
+                    filterDate,
+                );
+                result.filter = new Filter(line, filterFunction, new Explanation(explanation));
             }
         } else {
             result.error = 'do not understand query filter (' + this.fieldName() + ' date)';
@@ -76,6 +89,28 @@ export abstract class DateField extends Field {
      */
     public abstract date(task: Task): Moment | null;
 
+    /**
+     * Construct a string used to explain a date-based filter
+     * @param fieldName - for example, 'due'
+     * @param relationshipPrefixedWithSpace - for example ' before' or ''
+     * @param filterResultIfFieldMissing - whether the search matches tasks without the requested date value
+     * @param filterDate - the date used in the filter
+     */
+    public static getExplanationString(
+        fieldName: string,
+        relationshipPrefixedWithSpace: string,
+        filterResultIfFieldMissing: boolean,
+        filterDate: moment.Moment,
+    ) {
+        // Example of formatted date: '2024-01-02 (Tuesday 2nd January 2024)'
+        const actualDate = filterDate.format('YYYY-MM-DD (dddd Do MMMM YYYY)');
+        let result = `${fieldName} date is${relationshipPrefixedWithSpace} ${actualDate}`;
+        if (filterResultIfFieldMissing) {
+            result += ` OR no ${fieldName} date`;
+        }
+        return result;
+    }
+
     /**
      * Determine whether a task that does not have the particular date value
      * should be treated as a match. For example, 'starts' searches match all tasks
diff --git a/src/Query/Filter/Filter.ts b/src/Query/Filter/Filter.ts
index 62be314c..843f31a2 100644
--- a/src/Query/Filter/Filter.ts
+++ b/src/Query/Filter/Filter.ts
@@ -1,4 +1,5 @@
 import type { Task } from '../../Task';
+import type { Explanation } from '../Explain/Explanation';
 
 /**
  * A filtering function, that takes a Task object and returns
@@ -18,10 +19,12 @@ export type FilterFunction = (task: Task) => boolean;
  */
 export class Filter {
     readonly instruction: string;
+    readonly explanation: Explanation;
     public filterFunction: FilterFunction;
 
-    public constructor(instruction: string, filterFunction: FilterFunction) {
+    public constructor(instruction: string, filterFunction: FilterFunction, explanation: Explanation) {
         this.instruction = instruction;
+        this.explanation = explanation;
         this.filterFunction = filterFunction;
     }
 }
@@ -57,6 +60,10 @@ export class FilterOrErrorMessage {
         return this._filter;
     }
 
+    set filter(value: Filter | undefined) {
+        this._filter = value;
+    }
+
     get filterFunction(): FilterFunction | undefined {
         if (this._filter) {
             return this._filter.filterFunction;
@@ -65,22 +72,16 @@ export class FilterOrErrorMessage {
         }
     }
 
-    set filterFunction(value: FilterFunction | undefined) {
-        if (value) {
-            this._filter = new Filter(this.instruction, value);
-        } else {
-            this._filter = undefined;
-        }
-    }
-
     /**
      * Construct a FilterOrErrorMessage with the filter.
-     * @param instruction
-     * @param filter
+     *
+     * This function allows a meaningful {@link Explanation} to be supplied.
+     *
+     * @param filter - a {@link Filter}
      */
-    public static fromFilter(instruction: string, filter: FilterFunction): FilterOrErrorMessage {
-        const result = new FilterOrErrorMessage(instruction);
-        result.filterFunction = filter;
+    public static fromFilter(filter: Filter): FilterOrErrorMessage {
+        const result = new FilterOrErrorMessage(filter.instruction);
+        result.filter = filter;
         return result;
     }
 
diff --git a/src/Query/Filter/FilterInstruction.ts b/src/Query/Filter/FilterInstruction.ts
index b4fd0c9b..29786981 100644
--- a/src/Query/Filter/FilterInstruction.ts
+++ b/src/Query/Filter/FilterInstruction.ts
@@ -1,4 +1,5 @@
-import { FilterOrErrorMessage } from './Filter';
+import { Explanation } from '../Explain/Explanation';
+import { Filter, FilterOrErrorMessage } from './Filter';
 import type { FilterFunction } from './Filter';
 
 /**
@@ -33,7 +34,7 @@ export class FilterInstruction {
         const result = new FilterOrErrorMessage(line);
 
         if (line === this._instruction) {
-            result.filterFunction = this._filter;
+            result.filter = new Filter(line, this._filter, new Explanation(line));
             return result;
         }
 
diff --git a/src/Query/Filter/HappensDateField.ts b/src/Query/Filter/HappensDateField.ts
index 0329853a..0287eea1 100644
--- a/src/Query/Filter/HappensDateField.ts
+++ b/src/Query/Filter/HappensDateField.ts
@@ -2,8 +2,11 @@ import type { Moment } from 'moment';
 import type { Task } from '../../Task';
 import { DateParser } from '../DateParser';
 import { Sort } from '../Sort';
+import { Explanation } from '../Explain/Explanation';
 import { Field } from './Field';
-import { FilterOrErrorMessage } from './Filter';
+import { Filter, FilterOrErrorMessage } from './Filter';
+import { FilterInstructions } from './FilterInstructions';
+import { DateField } from './DateField';
 
 /**
  * Support the 'happens' search instruction, which searches all of
@@ -13,31 +16,34 @@ export class HappensDateField extends Field {
     private static readonly happensRegexp = /^happens (before|after|on)? ?(.*)/;
     private static readonly instructionForFieldPresence = 'has happens date';
     private static readonly instructionForFieldAbsence = 'no happens date';
+    private readonly filterInstructions: FilterInstructions;
+
+    constructor() {
+        super();
+        this.filterInstructions = new FilterInstructions();
+        this.filterInstructions.add(HappensDateField.instructionForFieldPresence, (task: Task) =>
+            this.dates(task).some((date) => date !== null),
+        );
+        this.filterInstructions.add(
+            HappensDateField.instructionForFieldAbsence,
+            (task: Task) => !this.dates(task).some((date) => date !== null),
+        );
+    }
 
     public canCreateFilterForLine(line: string): boolean {
-        if (line === HappensDateField.instructionForFieldPresence) {
-            return true;
-        }
-        if (line === HappensDateField.instructionForFieldAbsence) {
+        if (this.filterInstructions.canCreateFilterForLine(line)) {
             return true;
         }
         return super.canCreateFilterForLine(line);
     }
 
     public createFilterOrErrorMessage(line: string): FilterOrErrorMessage {
-        const result = new FilterOrErrorMessage(line);
-
-        if (line === HappensDateField.instructionForFieldPresence) {
-            const result = new FilterOrErrorMessage(line);
-            result.filterFunction = (task: Task) => this.dates(task).some((date) => date !== null);
-            return result;
+        const filterResult = this.filterInstructions.createFilterOrErrorMessage(line);
+        if (filterResult.filter !== undefined) {
+            return filterResult;
         }
 
-        if (line === HappensDateField.instructionForFieldAbsence) {
-            const result = new FilterOrErrorMessage(line);
-            result.filterFunction = (task: Task) => !this.dates(task).some((date) => date !== null);
-            return result;
-        }
+        const result = new FilterOrErrorMessage(line);
 
         const happensMatch = Field.getMatch(this.filterRegExp(), line);
         if (happensMatch !== null) {
@@ -45,19 +51,31 @@ export class HappensDateField extends Field {
             if (!filterDate.isValid()) {
                 result.error = 'do not understand happens date';
             } else {
+                let filterFunction;
+                let relative;
                 if (happensMatch[1] === 'before') {
-                    result.filterFunction = (task: Task) => {
+                    filterFunction = (task: Task) => {
                         return this.dates(task).some((date) => date && date.isBefore(filterDate));
                     };
+                    relative = ' ' + happensMatch[1];
                 } else if (happensMatch[1] === 'after') {
-                    result.filterFunction = (task: Task) => {
+                    filterFunction = (task: Task) => {
                         return this.dates(task).some((date) => date && date.isAfter(filterDate));
                     };
+                    relative = ' ' + happensMatch[1];
                 } else {
-                    result.filterFunction = (task: Task) => {
+                    filterFunction = (task: Task) => {
                         return this.dates(task).some((date) => date && date.isSame(filterDate));
                     };
+                    relative = ' on';
                 }
+                const explanation = DateField.getExplanationString(
+                    'due, start or scheduled',
+                    relative,
+                    false,
+                    filterDate,
+                );
+                result.filter = new Filter(line, filterFunction, new Explanation(explanation));
             }
         } else {
             result.error = 'do not understand query filter (happens date)';
diff --git a/src/Query/Filter/PriorityField.ts b/src/Query/Filter/PriorityField.ts
index a32ed320..de46e613 100644
--- a/src/Query/Filter/PriorityField.ts
+++ b/src/Query/Filter/PriorityField.ts
@@ -1,6 +1,7 @@
 import { Priority, Task } from '../../Task';
+import { Explanation } from '../Explain/Explanation';
 import { Field } from './Field';
-import { FilterOrErrorMessage } from './Filter';
+import { Filter, FilterOrErrorMessage } from './Filter';
 
 export class PriorityField extends Field {
     private static readonly priorityRegexp = /^priority (is )?(above|below)? ?(low|none|medium|high)/;
@@ -32,6 +33,7 @@ export class PriorityField extends Field {
                 return result;
             }
 
+            let explanation = line;
             let filter;
             if (priorityMatch[2] === 'above') {
                 filter = (task: Task) => (task.priority ? task.priority.localeCompare(filterPriority!) < 0 : false);
@@ -39,9 +41,10 @@ export class PriorityField extends Field {
                 filter = (task: Task) => (task.priority ? task.priority.localeCompare(filterPriority!) > 0 : false);
             } else {
                 filter = (task: Task) => (task.priority ? task.priority === filterPriority : false);
+                explanation = `${this.fieldName()} is ${filterPriorityString}`;
             }
 
-            result.filterFunction = filter;
+            result.filter = new Filter(line, filter, new Explanation(explanation));
         } else {
             result.error = 'do not understand query filter (priority)';
         }
diff --git a/src/Query/Filter/TextField.ts b/src/Query/Filter/TextField.ts
index 202eb62e..db93db89 100644
--- a/src/Query/Filter/TextField.ts
+++ b/src/Query/Filter/TextField.ts
@@ -2,9 +2,10 @@ import type { Task } from '../../Task';
 import { SubstringMatcher } from '../Matchers/SubstringMatcher';
 import { RegexMatcher } from '../Matchers/RegexMatcher';
 import type { IStringMatcher } from '../Matchers/IStringMatcher';
+import { Explanation } from '../Explain/Explanation';
 import { Field } from './Field';
 import type { FilterFunction } from './Filter';
-import { FilterOrErrorMessage } from './Filter';
+import { Filter, FilterOrErrorMessage } from './Filter';
 
 /**
  * TextField is an abstract base class to help implement
@@ -46,7 +47,8 @@ export abstract class TextField extends Field {
         // and tests if it matches the string filtering rule
         // represented by this object.
         const negate = filterOperator.match(/not/) !== null;
-        return FilterOrErrorMessage.fromFilter(line, this.getFilter(matcher, negate));
+        const filter = new Filter(line, this.getFilter(matcher, negate), new Explanation(line));
+        return FilterOrErrorMessage.fromFilter(filter);
     }
 
     public static stringIncludesCaseInsensitive(haystack: string, needle: string): boolean {
diff --git a/src/Query/Query.ts b/src/Query/Query.ts
index f1ab1a43..155fb619 100644
--- a/src/Query/Query.ts
+++ b/src/Query/Query.ts
@@ -64,6 +64,7 @@ export class Query implements IQuery {
     private readonly hideOptionsRegexp =
         /^(hide|show) (task count|backlink|priority|start date|scheduled date|done date|due date|recurrence rule|edit button|urgency)/;
     private readonly shortModeRegexp = /^short/;
+    private readonly explainQueryRegexp = /^explain/;
 
     private readonly limitRegexp = /^limit (to )?(\d+)( tasks?)?/;
 
@@ -81,6 +82,9 @@ export class Query implements IQuery {
                     case this.shortModeRegexp.test(line):
                         this._layoutOptions.shortMode = true;
                         break;
+                    case this.explainQueryRegexp.test(line):
+                        this._layoutOptions.explainQuery = true;
+                        break;
                     case this.limitRegexp.test(line):
                         this.parseLimit({ line });
                         break;
@@ -104,6 +108,31 @@ export class Query implements IQuery {
             });
     }
 
+    public explainQuery(): string {
+        // TODO Include limit, if any
+        // TODO Include global filter, if any
+        // TODO State today's date (and maybe weekday)
+
+        const numberOfFilters = this.filters.length;
+        if (numberOfFilters === 0) {
+            return 'No filters supplied. All tasks will match the query.';
+        }
+
+        let result = 'All of:\n';
+        for (let i = 0; i < numberOfFilters; i++) {
+            if (i > 0) result += '\n';
+            const filter = this.filters[i];
+            const explanation = filter.explanation;
+            const unindentedExplanation = explanation.asString();
+            if (unindentedExplanation === filter.instruction) {
+                result += `  ${filter.instruction}\n`;
+            } else {
+                result += `  ${filter.instruction} =>\n${explanation.asString('    ')}\n`;
+            }
+        }
+        return result;
+    }
+
     public get limit(): number | undefined {
         return this._limit;
     }
diff --git a/src/QueryRenderer.ts b/src/QueryRenderer.ts
index 0c381bc5..8221d5b3 100644
--- a/src/QueryRenderer.ts
+++ b/src/QueryRenderer.ts
@@ -137,6 +137,10 @@ class QueryRenderChild extends MarkdownRenderChild {
                 `Render ${this.queryType} called for a block in active file "${this.filePath}", to select from ${tasks.length} tasks: plugin state: ${state}`,
             );
 
+            if (this.query.layoutOptions.explainQuery) {
+                this.createExplanation(content);
+            }
+
             const tasksSortedLimitedGrouped = this.query.applyQueryToTasks(tasks);
             for (const group of tasksSortedLimitedGrouped.groups) {
                 // If there were no 'group by' instructions, group.groupHeadings
@@ -161,6 +165,16 @@ class QueryRenderChild extends MarkdownRenderChild {
         this.containerEl.firstChild?.replaceWith(content);
     }
 
+    // Use the 'explain' instruction to enable this
+    private createExplanation(content: HTMLDivElement) {
+        const explanationAsString = this.query.explainQuery();
+
+        const explanationsBlock = content.createEl('pre');
+        explanationsBlock.addClasses(['plugin-tasks-query-explanation']);
+        explanationsBlock.setText('Explanation of query:\n\n' + explanationAsString);
+        content.appendChild(explanationsBlock);
+    }
+
     private async createTasksList({
         tasks,
         content,
diff --git a/styles.css b/styles.css
index b42497bf..04091f5c 100644
--- a/styles.css
+++ b/styles.css
@@ -1,3 +1,13 @@
+.plugin-tasks-query-explanation{
+    /* Prevent long explanation lines wrapping, so they are more readable,
+       especially on small screens.
+
+       A horizontal scroll bar will be displayed, if the explanation
+       is too wide to fit.
+     */
+    --code-white-space: pre;
+}
+
 .tasks-count {
     color: var(--text-faint);
     padding-left: 20px;
@@ -127,7 +137,7 @@
 
 .tasks-modal-dates > input, .tasks-modal-dates > code {
     grid-column: 2;
-    align-items: stretch; 
+    align-items: stretch;
 }
 
 .tasks-modal-dates > code {
@@ -186,4 +196,4 @@
     .tasks-modal-priorities > label {
         grid-row: 1;
     }
-}
\ No newline at end of file
+}


Test patch:
diff --git a/tests/CustomMatchers/CustomMatchersForFilters.ts b/tests/CustomMatchers/CustomMatchersForFilters.ts
index 9c148673..f2552329 100644
--- a/tests/CustomMatchers/CustomMatchersForFilters.ts
+++ b/tests/CustomMatchers/CustomMatchersForFilters.ts
@@ -1,3 +1,4 @@
+import { diff } from 'jest-diff';
 import type { Task } from '../../src/Task';
 import type { FilterOrErrorMessage } from '../../src/Query/Filter/Filter';
 import { fromLine } from '../TestHelpers';
@@ -57,6 +58,7 @@ declare global {
     namespace jest {
         interface Matchers<R> {
             toBeValid(): R;
+            toHaveExplanation(expectedExplanation: string): R;
             toMatchTask(task: Task): R;
             toMatchTaskFromLine(line: string): R;
             toMatchTaskWithHeading(heading: string | null): R;
@@ -65,6 +67,7 @@ declare global {
 
         interface Expect {
             toBeValid(): any;
+            toHaveExplanation(expectedExplanation: string): any;
             toMatchTask(task: Task): any;
             toMatchTaskFromLine(line: string): any;
             toMatchTaskWithHeading(heading: string | null): any;
@@ -73,6 +76,7 @@ declare global {
 
         interface InverseAsymmetricMatchers {
             toBeValid(): any;
+            toHaveExplanation(expectedExplanation: string): any;
             toMatchTask(task: Task): any;
             toMatchTaskFromLine(line: string): any;
             toMatchTaskWithHeading(heading: string | null): any;
@@ -102,6 +106,24 @@ export function toBeValid(filter: FilterOrErrorMessage) {
     };
 }
 
+export function toHaveExplanation(filter: FilterOrErrorMessage, expectedExplanation: string) {
+    expect(filter.filter).toBeDefined();
+    const received = filter.filter?.explanation.asString();
+    const matches = received === expectedExplanation;
+    if (!matches) {
+        return {
+            message: () =>
+                `unexpected incorrect explanation for "${filter.instruction}":\n` + diff(expectedExplanation, received),
+            pass: false,
+        };
+    }
+
+    return {
+        message: () => `explanation for "${filter.instruction}" should not be: "${received}"`,
+        pass: true,
+    };
+}
+
 export function toMatchTask(filter: FilterOrErrorMessage, task: Task) {
     const matches = filter.filterFunction!(task);
     if (!matches) {
diff --git a/tests/Query.test.ts b/tests/Query.test.ts
index 13789902..f4658b71 100644
--- a/tests/Query.test.ts
+++ b/tests/Query.test.ts
@@ -192,6 +192,7 @@ describe('Query parsing', () => {
         // In alphabetical order, please
         const filters = [
             '# Comment lines are ignored',
+            'explain',
             'hide backlink',
             'hide done date',
             'hide due date',
@@ -761,6 +762,39 @@ describe('Query', () => {
         });
     });
 
+    describe('explanations', () => {
+        it('should explain 0 filters', () => {
+            const input = '';
+            const query = new Query({ source: input });
+
+            const expectedDisplayText = 'No filters supplied. All tasks will match the query.';
+            expect(query.explainQuery()).toEqual(expectedDisplayText);
+        });
+
+        it('should explain 1 filter', () => {
+            const input = 'description includes hello';
+            const query = new Query({ source: input });
+
+            const expectedDisplayText = `All of:
+  description includes hello
+`;
+            expect(query.explainQuery()).toEqual(expectedDisplayText);
+        });
+
+        it('should explain 2 filters', () => {
+            const input = 'description includes hello\ndue 2012-01-23';
+            const query = new Query({ source: input });
+
+            const expectedDisplayText = `All of:
+  description includes hello
+
+  due 2012-01-23 =>
+    due date is on 2012-01-23 (Monday 23rd January 2012)
+`;
+            expect(query.explainQuery()).toEqual(expectedDisplayText);
+        });
+    });
+
     // This tests the parsing of 'group by' instructions.
     // Group.test.ts tests the actual grouping code.
     describe('grouping instructions', () => {
diff --git a/tests/Query/Explain/Explain.test.ts b/tests/Query/Explain/Explain.test.ts
new file mode 100644
index 00000000..4ddbad69
--- /dev/null
+++ b/tests/Query/Explain/Explain.test.ts
@@ -0,0 +1,57 @@
+import { Explanation } from '../../../src/Query/Explain/Explanation';
+
+describe('Explain', () => {
+    it('Explains single filter', () => {
+        const description = 'due date is before 2022-10-28';
+        const explanation = new Explanation(description);
+        expect(explanation.description).toEqual(description);
+        expect(explanation.children).toEqual([]);
+        expect(explanation.asString()).toEqual(description);
+    });
+
+    it('Explains an AND boolean combination', () => {
+        const children: Explanation[] = [new Explanation('x includes A'), new Explanation('x includes B')];
+        const explanation = Explanation.booleanAnd(children);
+        expect(explanation.description).toEqual('All of');
+        expect(explanation.children).toEqual(children);
+        const expected = `All of:
+  x includes A
+  x includes B`;
+        expect(explanation.asString()).toEqual(expected);
+    });
+
+    it('Explains an OR boolean combination', () => {
+        const children: Explanation[] = [new Explanation('x includes A'), new Explanation('x includes B')];
+        const explanation = Explanation.booleanOr(children);
+        expect(explanation.description).toEqual('At least one of');
+        expect(explanation.children).toEqual(children);
+    });
+
+    it('Explains a NOT boolean combination', () => {
+        const children: Explanation[] = [new Explanation('x includes A'), new Explanation('x includes B')];
+        const explanation = Explanation.booleanNot(children);
+        expect(explanation.description).toEqual('None of');
+        expect(explanation.children).toEqual(children);
+    });
+
+    it('Explains an XOR boolean combination', () => {
+        const children: Explanation[] = [new Explanation('x includes A'), new Explanation('x includes B')];
+        const explanation = Explanation.booleanXor(children);
+        expect(explanation.description).toEqual('Exactly one of');
+        expect(explanation.children).toEqual(children);
+    });
+
+    it('Explains a nested boolean combination', () => {
+        const not = Explanation.booleanOr([new Explanation('x1 includes A'), new Explanation('x1 includes B')]);
+        const or = Explanation.booleanOr([new Explanation('x2 includes C'), new Explanation('x2 includes D')]);
+        const and = Explanation.booleanAnd([not, or]);
+        const expected = `All of:
+  At least one of:
+    x1 includes A
+    x1 includes B
+  At least one of:
+    x2 includes C
+    x2 includes D`;
+        expect(and.asString()).toEqual(expected);
+    });
+});
diff --git a/tests/Query/Filter/BooleanField.test.ts b/tests/Query/Filter/BooleanField.test.ts
index cb1a00ba..afc25dd1 100644
--- a/tests/Query/Filter/BooleanField.test.ts
+++ b/tests/Query/Filter/BooleanField.test.ts
@@ -7,8 +7,14 @@ import type { FilterOrErrorMessage } from '../../../src/Query/Filter/Filter';
 import { TaskBuilder } from '../../TestingTools/TaskBuilder';
 import { testFilter } from '../../TestingTools/FilterTestHelpers';
 
+import { toHaveExplanation } from '../../CustomMatchers/CustomMatchersForFilters';
+
 window.moment = moment;
 
+expect.extend({
+    toHaveExplanation,
+});
+
 function testWithDescription(filter: FilterOrErrorMessage, description: string, expected: boolean) {
     const builder = new TaskBuilder();
     testFilter(filter, builder.description(description), expected);
@@ -150,3 +156,77 @@ describe('boolean query', () => {
         //      result.error = `unknown boolean operator '${token.value}'`;
     });
 });
+
+describe('explain boolean queries', () => {
+    it('should explain Boolean AND', () => {
+        const instruction = '(description includes d1) AND (priority medium)';
+        const filterOrMessage = new BooleanField().createFilterOrErrorMessage(instruction);
+        const expected = `All of:
+  description includes d1
+  priority is medium`;
+        expect(filterOrMessage).toHaveExplanation(expected);
+    });
+
+    it('should explain Boolean OR', () => {
+        const instruction = '(description includes d1) OR (priority medium)';
+        const filterOrMessage = new BooleanField().createFilterOrErrorMessage(instruction);
+        const expected = `At least one of:
+  description includes d1
+  priority is medium`;
+        expect(filterOrMessage).toHaveExplanation(expected);
+    });
+
+    it('should explain Boolean NOT', () => {
+        const instruction = 'NOT (description includes d1)';
+        const filterOrMessage = new BooleanField().createFilterOrErrorMessage(instruction);
+        expect(filterOrMessage).toHaveExplanation('None of:\n  description includes d1');
+    });
+
+    it('should explain Boolean XOR', () => {
+        const instruction = '(description includes d1) XOR (priority medium)';
+        const filterOrMessage = new BooleanField().createFilterOrErrorMessage(instruction);
+        const expected = `Exactly one of:
+  description includes d1
+  priority is medium`;
+        expect(filterOrMessage).toHaveExplanation(expected);
+    });
+
+    it('should explain 2 Boolean ORs', () => {
+        const instruction = '(description includes d1) OR (description includes d2) OR (priority medium)';
+        const filterOrMessage = new BooleanField().createFilterOrErrorMessage(instruction);
+        // TODO This creates a nested 'At least one of' inside the first one.
+        //      It's technically correct, but hard for users to read.
+        //      I would like to try and somehow collate, or run together, runs of AND and OR operations
+        const expected = `At least one of:
+  At least one of:
+    description includes d1
+    description includes d2
+  priority is medium`;
+        expect(filterOrMessage).toHaveExplanation(expected);
+    });
+
+    it('should explain 9 Boolean ANDs', () => {
+        const instruction =
+            '(description includes 1) AND (description includes 2) AND (description includes 3) AND (description includes 4) AND (description includes 5) AND (description includes 6) AND (description includes 7) AND (description includes 8) AND (description includes 9)';
+        const filterOrMessage = new BooleanField().createFilterOrErrorMessage(instruction);
+        expect(filterOrMessage.filter?.explanation.asString()).toMatchInlineSnapshot(`
+            "All of:
+              All of:
+                All of:
+                  All of:
+                    All of:
+                      All of:
+                        All of:
+                          All of:
+                            description includes 1
+                            description includes 2
+                          description includes 3
+                        description includes 4
+                      description includes 5
+                    description includes 6
+                  description includes 7
+                description includes 8
+              description includes 9"
+        `);
+    });
+});
diff --git a/tests/Query/Filter/DoneDateField.test.ts b/tests/Query/Filter/DoneDateField.test.ts
index 2aa7cc6a..8d8d7aa5 100644
--- a/tests/Query/Filter/DoneDateField.test.ts
+++ b/tests/Query/Filter/DoneDateField.test.ts
@@ -6,9 +6,14 @@ import { DoneDateField } from '../../../src/Query/Filter/DoneDateField';
 import type { FilterOrErrorMessage } from '../../../src/Query/Filter/Filter';
 import { TaskBuilder } from '../../TestingTools/TaskBuilder';
 import { testFilter } from '../../TestingTools/FilterTestHelpers';
+import { toHaveExplanation } from '../../CustomMatchers/CustomMatchersForFilters';
 
 window.moment = moment;
 
+expect.extend({
+    toHaveExplanation,
+});
+
 function testTaskFilterForTaskWithDoneDate(filter: FilterOrErrorMessage, doneDate: string | null, expected: boolean) {
     const builder = new TaskBuilder();
     testFilter(filter, builder.doneDate(doneDate), expected);
@@ -33,3 +38,34 @@ describe('done date', () => {
         testTaskFilterForTaskWithDoneDate(filter, '2022-04-15', false);
     });
 });
+
+describe('explain done date queries', () => {
+    beforeAll(() => {
+        jest.useFakeTimers();
+        jest.setSystemTime(new Date(2022, 0, 15)); // 2022-01-15
+    });
+
+    afterAll(() => {
+        jest.useRealTimers();
+    });
+
+    it('should explain date before', () => {
+        const filterOrMessage = new DoneDateField().createFilterOrErrorMessage('done before 2023-01-02');
+        expect(filterOrMessage).toHaveExplanation('done date is before 2023-01-02 (Monday 2nd January 2023)');
+    });
+
+    it('should explain date with explicit on', () => {
+        const filterOrMessage = new DoneDateField().createFilterOrErrorMessage('done on 2024-01-02');
+        expect(filterOrMessage).toHaveExplanation('done date is on 2024-01-02 (Tuesday 2nd January 2024)');
+    });
+
+    it('should explain date with implicit on', () => {
+        const filterOrMessage = new DoneDateField().createFilterOrErrorMessage('done 2024-01-02');
+        expect(filterOrMessage).toHaveExplanation('done date is on 2024-01-02 (Tuesday 2nd January 2024)');
+    });
+
+    it('should show value of relative dates', () => {
+        const filterOrMessage = new DoneDateField().createFilterOrErrorMessage('done after today');
+        expect(filterOrMessage).toHaveExplanation('done date is after 2022-01-15 (Saturday 15th January 2022)');
+    });
+});
diff --git a/tests/Query/Filter/DueDateField.test.ts b/tests/Query/Filter/DueDateField.test.ts
index 09c6f72b..c1d0ce48 100644
--- a/tests/Query/Filter/DueDateField.test.ts
+++ b/tests/Query/Filter/DueDateField.test.ts
@@ -6,9 +6,14 @@ import { DueDateField } from '../../../src/Query/Filter/DueDateField';
 import type { FilterOrErrorMessage } from '../../../src/Query/Filter/Filter';
 import { TaskBuilder } from '../../TestingTools/TaskBuilder';
 import { testFilter } from '../../TestingTools/FilterTestHelpers';
+import { toHaveExplanation } from '../../CustomMatchers/CustomMatchersForFilters';
 
 window.moment = moment;
 
+expect.extend({
+    toHaveExplanation,
+});
+
 function testTaskFilterForTaskWithDueDate(filter: FilterOrErrorMessage, dueDate: string | null, expected: boolean) {
     const builder = new TaskBuilder();
     testFilter(filter, builder.dueDate(dueDate), expected);
@@ -38,3 +43,15 @@ describe('due date', () => {
         testTaskFilterForTaskWithDueDate(filter, '2022-13-01', true); // month 13 not valid
     });
 });
+
+describe('explain due date queries', () => {
+    it('should explain explicit date', () => {
+        const filterOrMessage = new DueDateField().createFilterOrErrorMessage('due before 2023-01-02');
+        expect(filterOrMessage).toHaveExplanation('due date is before 2023-01-02 (Monday 2nd January 2023)');
+    });
+
+    it('implicit "on" gets added to explanation', () => {
+        const filterOrMessage = new DueDateField().createFilterOrErrorMessage('due 2023-01-02');
+        expect(filterOrMessage).toHaveExplanation('due date is on 2023-01-02 (Monday 2nd January 2023)');
+    });
+});
diff --git a/tests/Query/Filter/Filter.test.ts b/tests/Query/Filter/Filter.test.ts
index 71855532..1b5492e2 100644
--- a/tests/Query/Filter/Filter.test.ts
+++ b/tests/Query/Filter/Filter.test.ts
@@ -1,7 +1,8 @@
-import { Filter } from '../../../src/Query/Filter/Filter';
+import { Filter, FilterOrErrorMessage } from '../../../src/Query/Filter/Filter';
 import type { FilterFunction } from '../../../src/Query/Filter/Filter';
 import type { Task } from '../../../src/Task';
 import { toMatchTaskFromLine } from '../../CustomMatchers/CustomMatchersForFilters';
+import { Explanation } from '../../../src/Query/Explain/Explanation';
 
 expect.extend({
     toMatchTaskFromLine,
@@ -13,9 +14,26 @@ describe('Filter', () => {
             return task.description.length > 20;
         };
         const line = 'some sample instruction';
-        const filter = new Filter(line, filterFunction);
+        const filter = new Filter(line, filterFunction, new Explanation(line));
 
         expect(filter.instruction).toEqual(line);
+        expect(filter.explanation.asString()).toEqual(line);
         expect(filter.filterFunction).not.toBeUndefined();
     });
 });
+
+describe('FilterOrErrorMessage', () => {
+    it('should construct from Filter', () => {
+        const filterFunction: FilterFunction = (task: Task) => {
+            return task.description.length > 20;
+        };
+
+        const instruction = 'description > 20';
+        const explanation = 'description longer than 20 chars';
+        const filter = new Filter(instruction, filterFunction, new Explanation(explanation));
+
+        const filterOrErrorMessage = FilterOrErrorMessage.fromFilter(filter);
+        expect(filterOrErrorMessage.filter?.instruction).toEqual(instruction);
+        expect(filterOrErrorMessage.filter?.explanation.asString()).toEqual(explanation);
+    });
+});
diff --git a/tests/Query/Filter/HappensDateField.test.ts b/tests/Query/Filter/HappensDateField.test.ts
index 573d38d1..9376a690 100644
--- a/tests/Query/Filter/HappensDateField.test.ts
+++ b/tests/Query/Filter/HappensDateField.test.ts
@@ -5,9 +5,14 @@ import moment from 'moment';
 import { HappensDateField } from '../../../src/Query/Filter/HappensDateField';
 import { TaskBuilder } from '../../TestingTools/TaskBuilder';
 import { testFilter } from '../../TestingTools/FilterTestHelpers';
+import { toHaveExplanation } from '../../CustomMatchers/CustomMatchersForFilters';
 
 window.moment = moment;
 
+expect.extend({
+    toHaveExplanation,
+});
+
 describe('happens date', () => {
     it('by happens date presence', () => {
         // Arrange
@@ -75,3 +80,42 @@ describe('accessing earliest happens date', () => {
         );
     });
 });
+
+describe('explain happens date queries', () => {
+    beforeAll(() => {
+        jest.useFakeTimers();
+        jest.setSystemTime(new Date(2022, 0, 15)); // 2022-01-15
+    });
+
+    afterAll(() => {
+        jest.useRealTimers();
+    });
+
+    it('should explain date before', () => {
+        const filterOrMessage = new HappensDateField().createFilterOrErrorMessage('happens before 2023-01-02');
+        expect(filterOrMessage).toHaveExplanation(
+            'due, start or scheduled date is before 2023-01-02 (Monday 2nd January 2023)',
+        );
+    });
+
+    it('should explain date with explicit on', () => {
+        const filterOrMessage = new HappensDateField().createFilterOrErrorMessage('happens on 2024-01-02');
+        expect(filterOrMessage).toHaveExplanation(
+            'due, start or scheduled date is on 2024-01-02 (Tuesday 2nd January 2024)',
+        );
+    });
+
+    it('should explain date with implicit on', () => {
+        const filterOrMessage = new HappensDateField().createFilterOrErrorMessage('happens 2024-01-02');
+        expect(filterOrMessage).toHaveExplanation(
+            'due, start or scheduled date is on 2024-01-02 (Tuesday 2nd January 2024)',
+        );
+    });
+
+    it('should show value of relative dates', () => {
+        const filterOrMessage = new HappensDateField().createFilterOrErrorMessage('happens after today');
+        expect(filterOrMessage).toHaveExplanation(
+            'due, start or scheduled date is after 2022-01-15 (Saturday 15th January 2022)',
+        );
+    });
+});
diff --git a/tests/Query/Filter/PriorityField.test.ts b/tests/Query/Filter/PriorityField.test.ts
index 0d841488..a678b82f 100644
--- a/tests/Query/Filter/PriorityField.test.ts
+++ b/tests/Query/Filter/PriorityField.test.ts
@@ -3,6 +3,12 @@ import { TaskBuilder } from '../../TestingTools/TaskBuilder';
 import { testFilter } from '../../TestingTools/FilterTestHelpers';
 import { PriorityField } from '../../../src/Query/Filter/PriorityField';
 
+import { toHaveExplanation } from '../../CustomMatchers/CustomMatchersForFilters';
+
+expect.extend({
+    toHaveExplanation,
+});
+
 function testTaskFilterForTaskWithPriority(filter: string, priority: Priority, expected: boolean) {
     const builder = new TaskBuilder();
     const filterOrError = new PriorityField().createFilterOrErrorMessage(filter);
@@ -69,3 +75,18 @@ describe('priority error cases', () => {
         expect(filter.error).toBe('do not understand query filter (priority)');
     });
 });
+
+describe('explain priority', () => {
+    it('simple case just repeats the-supplied line', () => {
+        const field = new PriorityField();
+        const instruction = 'priority above none';
+        const filterOrMessage = field.createFilterOrErrorMessage(instruction);
+        expect(filterOrMessage).toHaveExplanation(instruction);
+    });
+
+    it('implicit "is" gets added to description', () => {
+        const field = new PriorityField();
+        const filterOrMessage = field.createFilterOrErrorMessage('priority high');
+        expect(filterOrMessage).toHaveExplanation('priority is high');
+    });
+});
diff --git a/tests/Query/Filter/ScheduledDateField.test.ts b/tests/Query/Filter/ScheduledDateField.test.ts
new file mode 100644
index 00000000..37ba6487
--- /dev/null
+++ b/tests/Query/Filter/ScheduledDateField.test.ts
@@ -0,0 +1,24 @@
+/**
+ * @jest-environment jsdom
+ */
+import moment from 'moment';
+import { toHaveExplanation } from '../../CustomMatchers/CustomMatchersForFilters';
+import { ScheduledDateField } from '../../../src/Query/Filter/ScheduledDateField';
+
+window.moment = moment;
+
+expect.extend({
+    toHaveExplanation,
+});
+
+describe('explain scheduled date queries', () => {
+    it('should explain explicit date', () => {
+        const filterOrMessage = new ScheduledDateField().createFilterOrErrorMessage('scheduled before 2023-01-02');
+        expect(filterOrMessage).toHaveExplanation('scheduled date is before 2023-01-02 (Monday 2nd January 2023)');
+    });
+
+    it('implicit "on" gets added to explanation', () => {
+        const filterOrMessage = new ScheduledDateField().createFilterOrErrorMessage('scheduled 2023-01-02');
+        expect(filterOrMessage).toHaveExplanation('scheduled date is on 2023-01-02 (Monday 2nd January 2023)');
+    });
+});
diff --git a/tests/Query/Filter/StartDateField.test.ts b/tests/Query/Filter/StartDateField.test.ts
new file mode 100644
index 00000000..a17fea15
--- /dev/null
+++ b/tests/Query/Filter/StartDateField.test.ts
@@ -0,0 +1,28 @@
+/**
+ * @jest-environment jsdom
+ */
+import moment from 'moment';
+import { toHaveExplanation } from '../../CustomMatchers/CustomMatchersForFilters';
+import { StartDateField } from '../../../src/Query/Filter/StartDateField';
+
+window.moment = moment;
+
+expect.extend({
+    toHaveExplanation,
+});
+
+describe('explain start date queries', () => {
+    it('should explain explicit date', () => {
+        const filterOrMessage = new StartDateField().createFilterOrErrorMessage('starts before 2023-01-02');
+        expect(filterOrMessage).toHaveExplanation(
+            'start date is before 2023-01-02 (Monday 2nd January 2023) OR no start date',
+        );
+    });
+
+    it('implicit "on" gets added to explanation', () => {
+        const filterOrMessage = new StartDateField().createFilterOrErrorMessage('starts 2023-01-02');
+        expect(filterOrMessage).toHaveExplanation(
+            'start date is on 2023-01-02 (Monday 2nd January 2023) OR no start date',
+        );
+    });
+});