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

Issue:
Ability to limit number of tasks by group
### ⚠️ Please check that this feature request hasn't been suggested before.

- [X] I searched previous [Ideas in Discussions](https://github.com/obsidian-tasks-group/obsidian-tasks/discussions/categories/ideas-any-new-feature-requests-go-in-issues-please?discussions_q=category%3A%22Ideas%3A+Any+New+Feature+Requests+go+in+Issues+please%22+-label%3A%22status%3A+released%22+-label%3Aduplicate+sort%3Atop) didn't find any similar feature requests.
- [X] I searched previous [Issues](https://github.com/obsidian-tasks-group/obsidian-tasks/issues?q=is%3Aopen+is%3Aissue+label%3A%22type%3A+enhancement%22+sort%3Acomments-desc) didn't find any similar feature requests.

### 🔖 Feature description

Currently the limit parameter limits the total number of tasks,
I'd like to be able to limit by number of tasks in a group, for instance to just show the very next satisfying task in a file.

### ✔️ Solution

The could have a syntax like:
```
group by filename limit 1
```
OR (as per @claremacrae's comment to make it easier to implement)
```
limit group 1
```

### ❓ Alternatives

_No response_

### 📝 Additional Context

_No response_

Pull request description:
feat: Add 'limit groups to <number> tasks'
<!--- Provide a general summary of your changes in the Title above -->

# Description

Add `limit groups <number>` & `limit groups to <number> tasks` instructions.

## Motivation and Context

Fixes #1674 , implementation of limitation of tasks in groups.

## How has this been tested?

New unit tests added.

## 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:

- [ ] **Bug fix** (prefix: `fix` - non-breaking change which fixes an issue)
- [x] **New feature** (prefix: `feat` - non-breaking change which adds functionality)
- [ ] **Breaking change** (prefix: `feat!!` or `fix!!` - fix or feature that would cause existing functionality to not work as expected)
- [x] **Documentation** (prefix: `docs` - improvements to any documentation content **for users**)
- [ ] **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))
- [ ] **Contributing Guidelines** (prefix: `contrib` - any improvements to documentation content **for contributors** - see [Contributing to Tasks](https://publish.obsidian.md/tasks-contributing/))

Internal changes:

- [ ] **Refactor** (prefix: `refactor` - non-breaking change which only improves the design or structure of existing code, and making no changes to its external behaviour)
- [ ] **Tests** (prefix: `test` - additions and improvements to unit tests and the smoke tests)
- [ ] **Infrastructure** (prefix: `chore` - examples include GitHub Actions, issue templates)

## 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://publish.obsidian.md/tasks-contributing/Documentation/About+Documentation) accordingly.
- [x] My change has adequate [Unit Test coverage](https://publish.obsidian.md/tasks-contributing/Testing/About+Testing).

## Terms

<!--
By submitting this pull request, you must agree to follow our
[contributing guide](https://publish.obsidian.md/tasks-contributing) and
[Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md).
Put an x in the boxes to confirm you agree.
-->

- [x] My contribution follow this project's [contributing guide](https://publish.obsidian.md/tasks-contributing)
- [x] I agree to follow this project's [Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md)


Patch:
diff --git a/docs/Queries/Limiting.md b/docs/Queries/Limiting.md
index 131abfa7..21a99ecf 100644
--- a/docs/Queries/Limiting.md
+++ b/docs/Queries/Limiting.md
@@ -4,8 +4,18 @@ publish: true
 
 # Limiting
 
-You can limit the number of tasks to show as query results.
+## Total tasks
+
+You can limit the number of total tasks to show as query results.
 Use the query string `limit to <number> tasks`.
 This will only list the `<number>` first results of the query.
 
 Shorthand is `limit <number>`.
+
+## Tasks per group
+
+You can also limit the max number of tasks in groups if grouping is used. Otherwise this limit is ignored.
+Use the query string `limit groups to <number> tasks`.
+This will only list the `<number>` first tasks in each group from the results of the query.
+
+Shorthand is `limit groups <number>`.
diff --git a/src/Query/Query.ts b/src/Query/Query.ts
index cdbca728..c82306c2 100644
--- a/src/Query/Query.ts
+++ b/src/Query/Query.ts
@@ -13,6 +13,7 @@ export class Query implements IQuery {
     public source: string;
 
     private _limit: number | undefined = undefined;
+    private _taskGroupLimit: number | undefined = undefined;
     private _layoutOptions: LayoutOptions = new LayoutOptions();
     private _filters: Filter[] = [];
     private _error: string | undefined = undefined;
@@ -24,7 +25,7 @@ export class Query implements IQuery {
     private readonly shortModeRegexp = /^short/;
     private readonly explainQueryRegexp = /^explain/;
 
-    private readonly limitRegexp = /^limit (to )?(\d+)( tasks?)?/;
+    private readonly limitRegexp = /^limit (groups )?(to )?(\d+)( tasks?)?/;
 
     private readonly commentRegexp = /^#.*/;
 
@@ -117,6 +118,14 @@ export class Query implements IQuery {
             result += '.\n';
         }
 
+        if (this._taskGroupLimit !== undefined) {
+            result += `\n\nAt most ${this._taskGroupLimit} task`;
+            if (this._taskGroupLimit !== 1) {
+                result += 's';
+            }
+            result += ' per group.\n';
+        }
+
         const { debugSettings } = getSettings();
         if (debugSettings.ignoreSortInstructions) {
             result +=
@@ -161,7 +170,14 @@ export class Query implements IQuery {
         const { debugSettings } = getSettings();
         const tasksSorted = debugSettings.ignoreSortInstructions ? tasks : Sort.by(this.sorting, tasks);
         const tasksSortedLimited = tasksSorted.slice(0, this.limit);
-        return new TaskGroups(this.grouping, tasksSortedLimited);
+
+        const taskGroups = new TaskGroups(this.grouping, tasksSortedLimited);
+
+        if (this._taskGroupLimit !== undefined) {
+            taskGroups.applyTaskLimit(this._taskGroupLimit);
+        }
+
+        return taskGroups;
     }
 
     private parseHideOptions({ line }: { line: string }): void {
@@ -222,11 +238,18 @@ export class Query implements IQuery {
 
     private parseLimit({ line }: { line: string }): void {
         const limitMatch = line.match(this.limitRegexp);
-        if (limitMatch !== null) {
-            // limitMatch[2] is per regex always digits and therefore parsable.
-            this._limit = Number.parseInt(limitMatch[2], 10);
-        } else {
+        if (limitMatch === null) {
             this._error = 'do not understand query limit';
+            return;
+        }
+
+        // limitMatch[3] is per regex always digits and therefore parsable.
+        const limitFromLine = Number.parseInt(limitMatch[3], 10);
+
+        if (limitMatch[1] !== undefined) {
+            this._taskGroupLimit = limitFromLine;
+        } else {
+            this._limit = limitFromLine;
         }
     }
 
diff --git a/src/Query/TaskGroup.ts b/src/Query/TaskGroup.ts
index a88aa373..da4a9eab 100644
--- a/src/Query/TaskGroup.ts
+++ b/src/Query/TaskGroup.ts
@@ -49,7 +49,7 @@ export class TaskGroup {
      * All the tasks that match the user's filters and that have the
      * group names exactly matching groups().
      */
-    public readonly tasks: Task[];
+    public tasks: Task[];
 
     /**
      * Constructor
@@ -68,6 +68,18 @@ export class TaskGroup {
         }
     }
 
+    /**
+     * Limits {@link tasks} array to a certain number. Tasks exceeding
+     * the limit will be removed from the end, shall be called on sorted tasks.
+     *
+     * @param limit number of tasks for the group to have. If greater
+     * than the task count, no action will be taken.
+     *
+     */
+    public applyTaskLimit(limit: number) {
+        this.tasks = this.tasks.slice(0, limit);
+    }
+
     /**
      * A markdown-format representation of all the tasks in this group.
      *
diff --git a/src/Query/TaskGroups.ts b/src/Query/TaskGroups.ts
index 92ba9f9a..51037e80 100644
--- a/src/Query/TaskGroups.ts
+++ b/src/Query/TaskGroups.ts
@@ -133,4 +133,37 @@ export class TaskGroups {
             group.setGroupHeadings(displayHeadingSelector.getHeadingsForTaskGroup(group.groups));
         }
     }
+
+    /**
+     * Limits all {@link groups} to have a certain number of tasks and
+     * recalculates the {@link _totalTaskCount} for consistency.
+     *
+     * If no `group by ...` instructions were provided the limit is ignored,
+     * however there will be one task group.
+     *
+     * @param limit number of tasks for each group to have.
+     *
+     */
+    public applyTaskLimit(limit: number) {
+        if (this._groupers.length === 0) {
+            return;
+        }
+
+        this._groups.forEach((group) => {
+            group.applyTaskLimit(limit);
+        });
+
+        this.calculateTotalTaskCount();
+    }
+
+    private calculateTotalTaskCount() {
+        let concatenatedTasks: Task[] = [];
+
+        this._groups.forEach((group) => {
+            concatenatedTasks = [...concatenatedTasks, ...group.tasks];
+        });
+
+        const uniqueTasks = [...new Set(concatenatedTasks)];
+        this._totalTaskCount = uniqueTasks.length;
+    }
 }


Test patch:
diff --git a/tests/Query.test.ts b/tests/Query.test.ts
index f65bb7e8..5c40ca7f 100644
--- a/tests/Query.test.ts
+++ b/tests/Query.test.ts
@@ -270,6 +270,8 @@ describe('Query parsing', () => {
             'hide urgency',
             'limit 42',
             'limit to 42 tasks',
+            'limit groups 31',
+            'limit groups to 31 tasks',
             'short mode',
             'short',
             'show backlink',
@@ -873,6 +875,17 @@ At most 1 task.
             const expectedDisplayText = `No filters supplied. All tasks will match the query.
 
 At most 0 tasks.
+`;
+            expect(query.explainQuery()).toEqual(expectedDisplayText);
+        });
+
+        it('should explain group limit 4', () => {
+            const input = 'limit groups 4';
+            const query = new Query({ source: input });
+
+            const expectedDisplayText = `No filters supplied. All tasks will match the query.
+
+At most 4 tasks per group.
 `;
             expect(query.explainQuery()).toEqual(expectedDisplayText);
         });
@@ -948,5 +961,49 @@ At most 0 tasks.
 `;
             expect('\n' + soleTaskGroup.tasksAsStringOfLines()).toStrictEqual(expectedTasks);
         });
+
+        it('should apply group limit correctly, after sorting tasks', () => {
+            // Arrange
+            const input = `
+                # sorting by description will sort the tasks alphabetically
+                sort by description
+
+                # grouping by status will give two groups: Done and Todo
+                group by status
+
+                # Apply a limit, to test which tasks make it to
+                limit groups 3
+                `;
+            const query = new Query({ source: input });
+
+            const tasksAsMarkdown = `
+- [x] Task 2 - will be in the first group and sorted after next one
+- [x] Task 1 - will be in the first group
+- [ ] Task 4 - will be sorted to 2nd place in the second group and pass the limit
+- [ ] Task 6 - will be sorted to 4th place in the second group and NOT pass the limit
+- [ ] Task 3 - will be sorted to 1st place in the second group and pass the limit
+- [ ] Task 5 - will be sorted to 3nd place in the second group and pass the limit
+            `;
+
+            const tasks = createTasksFromMarkdown(tasksAsMarkdown, 'some_markdown_file', 'Some Heading');
+
+            // Act
+            const groups = query.applyQueryToTasks(tasks);
+
+            // Assert
+            expect(groups.groups.length).toEqual(2);
+            expect(groups.totalTasksCount()).toEqual(5);
+            expect(groups.groups[0].tasksAsStringOfLines()).toMatchInlineSnapshot(`
+                "- [x] Task 1 - will be in the first group
+                - [x] Task 2 - will be in the first group and sorted after next one
+                "
+            `);
+            expect(groups.groups[1].tasksAsStringOfLines()).toMatchInlineSnapshot(`
+                "- [ ] Task 3 - will be sorted to 1st place in the second group and pass the limit
+                - [ ] Task 4 - will be sorted to 2nd place in the second group and pass the limit
+                - [ ] Task 5 - will be sorted to 3nd place in the second group and pass the limit
+                "
+            `);
+        });
     });
 });
diff --git a/tests/TaskGroups.test.ts b/tests/TaskGroups.test.ts
index 15bbb7e1..33b036ee 100644
--- a/tests/TaskGroups.test.ts
+++ b/tests/TaskGroups.test.ts
@@ -344,4 +344,123 @@ describe('Grouping tasks', () => {
             "
         `);
     });
+
+    it('should limit tasks in each group (no task overlapping across group)', () => {
+        // Arrange
+        const a = fromLine({ line: '- [ ] a', path: 'tasks_under_the_limit.md' });
+        const b = fromLine({ line: '- [ ] b', path: 'tasks_equal_to_limit.md' });
+        const c = fromLine({ line: '- [ ] c', path: 'tasks_equal_to_limit.md' });
+        const d = fromLine({ line: '- [ ] d', path: 'tasks_over_the_limit.md' });
+        const e = fromLine({ line: '- [ ] e', path: 'tasks_over_the_limit.md' });
+        const f = fromLine({ line: '- [ ] f', path: 'tasks_over_the_limit.md' });
+        const inputs = [a, b, c, d, e, f];
+
+        // Act
+        const grouping = [new PathField().createNormalGrouper()];
+        const groups = new TaskGroups(grouping, inputs);
+        groups.applyTaskLimit(2);
+
+        // Assert
+        expect(groups.totalTasksCount()).toEqual(5);
+        expect(groups.toString()).toMatchInlineSnapshot(`
+            "Groupers (if any):
+            - path
+
+            Group names: [tasks\\_equal\\_to\\_limit]
+            #### [path] tasks\\_equal\\_to\\_limit
+            - [ ] b
+            - [ ] c
+
+            ---
+
+            Group names: [tasks\\_over\\_the\\_limit]
+            #### [path] tasks\\_over\\_the\\_limit
+            - [ ] d
+            - [ ] e
+
+            ---
+
+            Group names: [tasks\\_under\\_the\\_limit]
+            #### [path] tasks\\_under\\_the\\_limit
+            - [ ] a
+
+            ---
+
+            5 tasks
+            "
+        `);
+    });
+
+    it('should limit tasks with tasks that overlap across multiple groups and correctly calculate unique tasks', () => {
+        // Arrange
+        const taskA = fromLine({ line: '- [ ] task A #tag1 #tag2' });
+        const taskB = fromLine({ line: '- [ ] task B #tag1 #tag3' });
+        const taskC = fromLine({ line: '- [ ] task C #tag3 #tag2' });
+        const taskD = fromLine({ line: '- [ ] task D #tag1 #tag2' });
+        const inputs = [taskA, taskB, taskC, taskD];
+
+        // Act
+        const grouping = [new TagsField().createNormalGrouper()];
+        const groups = new TaskGroups(grouping, inputs);
+        groups.applyTaskLimit(1);
+
+        // Assert
+        expect(groups.totalTasksCount()).toEqual(2);
+        expect(groups.toString()).toMatchInlineSnapshot(`
+            "Groupers (if any):
+            - tags
+
+            Group names: [#tag1]
+            #### [tags] #tag1
+            - [ ] task A #tag1 #tag2
+
+            ---
+
+            Group names: [#tag2]
+            #### [tags] #tag2
+            - [ ] task A #tag1 #tag2
+
+            ---
+
+            Group names: [#tag3]
+            #### [tags] #tag3
+            - [ ] task B #tag1 #tag3
+
+            ---
+
+            2 tasks
+            "
+        `);
+    });
+
+    it('should not limit tasks if no groups were specified', () => {
+        // Arrange
+        const taskA = fromLine({ line: '- [ ] task A' });
+        const taskB = fromLine({ line: '- [ ] task B' });
+        const taskC = fromLine({ line: '- [ ] task C' });
+        const taskD = fromLine({ line: '- [ ] task D' });
+        const inputs = [taskA, taskB, taskC, taskD];
+
+        // Act
+        const grouping: Grouper[] = [];
+        const groups = new TaskGroups(grouping, inputs);
+        groups.applyTaskLimit(1);
+
+        // Assert
+        expect(groups.totalTasksCount()).toEqual(4);
+        expect(groups.toString()).toMatchInlineSnapshot(`
+            "Groupers (if any):
+
+            Group names: []
+            - [ ] task A
+            - [ ] task B
+            - [ ] task C
+            - [ ] task D
+
+            ---
+
+            4 tasks
+            "
+        `);
+    });
 });