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

Issue:
Enhancement request: random sorting
Hi, I would like to have a `random` sort option for the task query. My reason for this is I'd like to create a task bucket. I have a long list of tasks and only want to pick a few tasks out of that each day, but not the same list for each day.

Pull request description:
feat: Add random sorting, with 'sort by random'
# Types of changes

<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->

Changes visible to users:

- [ ] **New feature** (prefix: `feat` - non-breaking change which adds functionality)
  - Issue/discussion:
      - #359
      - #330
- [x] **Documentation** (prefix: `docs` - improvements to any documentation content **for users**)

Internal changes:

- [x] **Tests** (prefix: `test` - additions and improvements to unit tests and the smoke tests)

## Description

- Add a new instruction `sort by random`.
- And document it

Credit:

- Thanks to @qelo for providing the implementation in https://github.com/obsidian-tasks-group/obsidian-tasks/discussions/330#discussioncomment-8902878
- Based on code in https://stackoverflow.com/a/52171480/104370

## Motivation and Context

- Fix #359
- Fix #330

## How has this been tested?

- Adding Jest automated tests
- Manual exploratory testing
- I have also been using this algorithm in my personal vault for some months

## Screenshots (if appropriate)

## 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 follows this project's [contributing guide](https://publish.obsidian.md/tasks-contributing)
- [x] I agree to follow this project's [Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md)


Patch:
diff --git a/docs/Queries/Sorting.md b/docs/Queries/Sorting.md
index 983a48f9..ffbe0830 100644
--- a/docs/Queries/Sorting.md
+++ b/docs/Queries/Sorting.md
@@ -504,6 +504,35 @@ sort by function task.originalMarkdown
 
 <!-- placeholder to force blank line after included text --><!-- endInclude -->
 
+### Random sorting
+
+> [!released]
+> Random sorting was introduced in Tasks X.Y.Z.
+
+This instruction sorts tasks in a random order:
+
+- `sort by random`
+
+The order is random but deterministic, calculated from task's description, and changes each day.
+
+> [!example] Example: Randomly select a few tasks to review
+> If you have a large vault with lots of undated tasks, reviewing them can be tedious: we have found it useful to be able to view a small selection every day.
+>
+> Review your backlog each day:
+>
+> - randomly select up to 10 undated tasks,
+> - then complete, update or delete a few of them!
+>
+> ````text
+> ```tasks
+> not done
+> no happens date
+> limit 10
+>
+> sort by random
+> ```
+> ````
+
 ## Sort by File Properties
 
 ### File Path
diff --git a/docs/Quick Reference.md b/docs/Quick Reference.md
index 73bf87d2..11314841 100644
--- a/docs/Quick Reference.md
+++ b/docs/Quick Reference.md
@@ -51,6 +51,7 @@ This table summarizes the filters and other options available inside a `tasks` b
 | `description (includes, does not include) <string>`<br>`description (regex matches, regex does not match) /regex/i`                                                                                                                                                                                                                                  | `sort by description`                       |                        |                        | `task.description`<br>`task.descriptionWithoutTags`                                                                            |
 | `has tags`<br>`no tags`<br>`tag (includes, does not include) <tag>`<br>`tags (include, do not include) <tag>`<br>`tag (regex matches, regex does not match) /regex/i`<br>`tags (regex matches, regex does not match) /regex/i`                                                                                                                       | `sort by tag`<br>`sort by tag <tag_number>` | `group by tags`        | `hide tags`            | `task.tags`                                                                                                                    |
 |                                                                                                                                                                                                                                                                                                                                                      |                                             |                        |                        | `task.originalMarkdown`                                                                                                        |
+|                                                                                                                                                                                                                                                                                                                                                      | `sort by random`                            |                        |                        |                                                                                                                                |
 | **[[About Scripting\|Scripting]]**                                                                                                                                                                                                                                                                                                                   |                                             |                        |                        |                                                                                                                                |
 | `filter by function`                                                                                                                                                                                                                                                                                                                                 | `sort by function`                          | `group by function`    |                        |                                                                                                                                |
 | **[[Combining Filters]]**                                                                                                                                                                                                                                                                                                                            |                                             |                        |                        |                                                                                                                                |
diff --git a/docs/What is New/Changelog.md b/docs/What is New/Changelog.md
index afcaca96..f425d30a 100644
--- a/docs/What is New/Changelog.md
+++ b/docs/What is New/Changelog.md
@@ -12,6 +12,8 @@ _In recent [Tasks releases](https://github.com/obsidian-tasks-group/obsidian-tas
 
 ## 7.x releases
 
+- X.Y.Z:
+  - Add [[Sorting#Random sorting|random sorting]], with `sort by random`
 - 7.10.0:
   - Right-click on any task date field in Reading and Query Results views to:
     - postpone Start, Scheduled and Due dates
diff --git a/src/Query/Filter/RandomField.ts b/src/Query/Filter/RandomField.ts
new file mode 100644
index 00000000..860bb1e4
--- /dev/null
+++ b/src/Query/Filter/RandomField.ts
@@ -0,0 +1,47 @@
+import type { Comparator } from '../Sort/Sorter';
+import type { Task } from '../../Task/Task';
+import { FilterInstructionsBasedField } from './FilterInstructionsBasedField';
+
+/**
+ * Sort tasks in a stable, random order.
+ *
+ * The sort order changes each day.
+ */
+export class RandomField extends FilterInstructionsBasedField {
+    public fieldName(): string {
+        return 'random';
+    }
+
+    // -----------------------------------------------------------------------------------------------------------------
+    // Sorting
+    // -----------------------------------------------------------------------------------------------------------------
+
+    supportsSorting(): boolean {
+        return true;
+    }
+
+    public comparator(): Comparator {
+        return (a: Task, b: Task) => {
+            return this.sortKey(a) - this.sortKey(b);
+        };
+    }
+
+    public sortKey(task: Task): number {
+        // Credit:
+        //   - @qelo https://github.com/obsidian-tasks-group/obsidian-tasks/discussions/330#discussioncomment-8902878
+        //   - Based on TinySimpleHash in https://stackoverflow.com/a/52171480/104370
+        const tinySimpleHash = (s: string): number => {
+            let i = 0; // Index for iterating over the string
+            let h = 9; // Initial hash value
+
+            while (i < s.length) {
+                h = Math.imul(h ^ s.charCodeAt(i++), 9 ** 9);
+            }
+
+            return h ^ (h >>> 9);
+        };
+
+        const currentDate = window.moment().format('Y-MM-DD');
+        return tinySimpleHash(currentDate + ' ' + task.description);
+    }
+}
diff --git a/src/Query/FilterParser.ts b/src/Query/FilterParser.ts
index 3e94c347..0b8360e9 100644
--- a/src/Query/FilterParser.ts
+++ b/src/Query/FilterParser.ts
@@ -31,6 +31,7 @@ import { CancelledDateField } from './Filter/CancelledDateField';
 import { BlockingField } from './Filter/BlockingField';
 import { IdField } from './Filter/IdField';
 import { DependsOnField } from './Filter/DependsOnField';
+import { RandomField } from './Filter/RandomField';
 
 // When parsing a query the fields are tested one by one according to this order.
 // Since BooleanField is a meta-field, which needs to aggregate a few fields together, it is intended to
@@ -66,6 +67,7 @@ export const fieldCreators: EndsWith<BooleanField> = [
     () => new IdField(),
     () => new DependsOnField(),
     () => new BlockingField(),
+    () => new RandomField(),
     () => new BooleanField(), // --- Please make sure to keep BooleanField last (see comment above) ---
 ];
 


Test patch:
diff --git a/tests/Query/Filter/RandomField.test.ts b/tests/Query/Filter/RandomField.test.ts
new file mode 100644
index 00000000..79c3bcd6
--- /dev/null
+++ b/tests/Query/Filter/RandomField.test.ts
@@ -0,0 +1,77 @@
+/**
+ * @jest-environment jsdom
+ */
+import moment from 'moment';
+
+import { RandomField } from '../../../src/Query/Filter/RandomField';
+import { fromLine } from '../../TestingTools/TestHelpers';
+import { expectTaskComparesEqual } from '../../CustomMatchers/CustomMatchersForSorting';
+import { TaskBuilder } from '../../TestingTools/TaskBuilder';
+
+window.moment = moment;
+
+const field = new RandomField();
+
+beforeAll(() => {
+    jest.useFakeTimers();
+    jest.setSystemTime(new Date('2024-01-23'));
+});
+
+afterAll(() => {
+    jest.useRealTimers();
+});
+
+describe('filtering by random', () => {
+    it('should be named random', () => {
+        expect(field.fieldName()).toEqual('random');
+    });
+});
+
+describe('sorting by random', () => {
+    it('should support sorting', () => {
+        expect(field.supportsSorting()).toEqual(true);
+    });
+
+    it('should sort identical tasks the same', () => {
+        const sorter = field.createNormalSorter();
+        const task1 = fromLine({ line: '- [ ] Some description' });
+
+        expectTaskComparesEqual(sorter, task1, task1);
+    });
+
+    it('sort key should ignore task properties except description', () => {
+        const fullyPopulatedTask = TaskBuilder.createFullyPopulatedTask();
+        const taskWithSameDescription = new TaskBuilder().description(fullyPopulatedTask.description).build();
+        expect(field.sortKey(fullyPopulatedTask)).toEqual(field.sortKey(taskWithSameDescription));
+    });
+
+    it('sort key should not change, at different times', () => {
+        const task1 = fromLine({ line: '- [ ] My sort key should be same, regardless of time' });
+
+        jest.setSystemTime(new Date('2024-10-19 10:42'));
+        const sortKeyAtTime1 = field.sortKey(task1);
+
+        jest.setSystemTime(new Date('2024-10-19 21:05'));
+        const sortKeyAtTime2 = field.sortKey(task1);
+
+        expect(sortKeyAtTime1).toEqual(sortKeyAtTime2);
+    });
+
+    it('sort key should change on different dates', () => {
+        const task1 = fromLine({ line: '- [ ] My sort key should differ on different dates' });
+
+        jest.setSystemTime(new Date('2024-01-23'));
+        const sortKeyOnDay1 = field.sortKey(task1);
+
+        jest.setSystemTime(new Date('2024-01-24'));
+        const sortKeyOnDay2 = field.sortKey(task1);
+
+        expect(sortKeyOnDay1).not.toEqual(sortKeyOnDay2);
+    });
+});
+
+describe('grouping by random', () => {
+    it('should not support grouping', () => {
+        expect(field.supportsGrouping()).toEqual(false);
+    });
+});
diff --git a/tests/Query/Query.test.ts b/tests/Query/Query.test.ts
index 8e9551d2..be915775 100644
--- a/tests/Query/Query.test.ts
+++ b/tests/Query/Query.test.ts
@@ -247,7 +247,7 @@ describe('Query parsing', () => {
 
         describe.each(namedFields)('has sufficient sample "filter" lines for field "%s"', ({ name, field }) => {
             function fieldDoesNotSupportFiltering() {
-                return name === 'backlink' || name === 'urgency';
+                return name === 'backlink' || name === 'urgency' || name === 'random';
             }
 
             // This is a bit weaker than the corresponding tests for 'sort by' and 'group by',
@@ -311,6 +311,8 @@ describe('Query parsing', () => {
             'sort by path reverse',
             'sort by priority',
             'sort by priority reverse',
+            'sort by random',
+            'sort by random reverse',
             'sort by recurring',
             'sort by recurring reverse',
             'sort by scheduled',