Skip to content

Commit ecc4c15

Browse files
authored
feat: Add 'limit groups to <number> tasks' (#1963)
* refactor: add limitTo() and test * feat: apply limit to the tasks * feat: adjust total task count after deletion * refactor: extract TaskGroup.limitTo() * refactor: rename vars * feat: parse limit group instruction * refactor: extract taskGroups to a var * refactor: limitTo() accepts number or undefined * feat: explain group limit * fix: remove tasks only if needed * feat: apply group limits in queries * refactor: rename new methods * fix: recount tasks after the limit has been applied * refactor: extract calculateTotalTaskCount() * refactor: clarify calculateTotalTaskCount() * refactor: remove return value from applyTaskLimit() * test: additional count test and better wordings * refactor: move undefined check to Query * docs: add jsdoc comments * refactor: reverse if-else statement to catch error * test: explicit wordings in TaskGroups.test.ts * test: verify that sorting is applied with group limit * fix: should not apply group limit if no groups were specified * docs: add group limit * refactor: use slice() instead of splice() in TaskGroup.applyTaskLimit() * test: fix test comment * docs: better jsdoc of TaskGroups.applyTaskLimit() * feat: modify 'group' to 'groups' in queries * docs: update docs to new instruction
1 parent 46ef322 commit ecc4c15

File tree

6 files changed

+262
-8
lines changed

6 files changed

+262
-8
lines changed

docs/Queries/Limiting.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,18 @@ publish: true
44

55
# Limiting
66

7-
You can limit the number of tasks to show as query results.
7+
## Total tasks
8+
9+
You can limit the number of total tasks to show as query results.
810
Use the query string `limit to <number> tasks`.
911
This will only list the `<number>` first results of the query.
1012

1113
Shorthand is `limit <number>`.
14+
15+
## Tasks per group
16+
17+
You can also limit the max number of tasks in groups if grouping is used. Otherwise this limit is ignored.
18+
Use the query string `limit groups to <number> tasks`.
19+
This will only list the `<number>` first tasks in each group from the results of the query.
20+
21+
Shorthand is `limit groups <number>`.

src/Query/Query.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export class Query implements IQuery {
1313
public source: string;
1414

1515
private _limit: number | undefined = undefined;
16+
private _taskGroupLimit: number | undefined = undefined;
1617
private _layoutOptions: LayoutOptions = new LayoutOptions();
1718
private _filters: Filter[] = [];
1819
private _error: string | undefined = undefined;
@@ -24,7 +25,7 @@ export class Query implements IQuery {
2425
private readonly shortModeRegexp = /^short/;
2526
private readonly explainQueryRegexp = /^explain/;
2627

27-
private readonly limitRegexp = /^limit (to )?(\d+)( tasks?)?/;
28+
private readonly limitRegexp = /^limit (groups )?(to )?(\d+)( tasks?)?/;
2829

2930
private readonly commentRegexp = /^#.*/;
3031

@@ -117,6 +118,14 @@ export class Query implements IQuery {
117118
result += '.\n';
118119
}
119120

121+
if (this._taskGroupLimit !== undefined) {
122+
result += `\n\nAt most ${this._taskGroupLimit} task`;
123+
if (this._taskGroupLimit !== 1) {
124+
result += 's';
125+
}
126+
result += ' per group.\n';
127+
}
128+
120129
const { debugSettings } = getSettings();
121130
if (debugSettings.ignoreSortInstructions) {
122131
result +=
@@ -161,7 +170,14 @@ export class Query implements IQuery {
161170
const { debugSettings } = getSettings();
162171
const tasksSorted = debugSettings.ignoreSortInstructions ? tasks : Sort.by(this.sorting, tasks);
163172
const tasksSortedLimited = tasksSorted.slice(0, this.limit);
164-
return new TaskGroups(this.grouping, tasksSortedLimited);
173+
174+
const taskGroups = new TaskGroups(this.grouping, tasksSortedLimited);
175+
176+
if (this._taskGroupLimit !== undefined) {
177+
taskGroups.applyTaskLimit(this._taskGroupLimit);
178+
}
179+
180+
return taskGroups;
165181
}
166182

167183
private parseHideOptions({ line }: { line: string }): void {
@@ -222,11 +238,18 @@ export class Query implements IQuery {
222238

223239
private parseLimit({ line }: { line: string }): void {
224240
const limitMatch = line.match(this.limitRegexp);
225-
if (limitMatch !== null) {
226-
// limitMatch[2] is per regex always digits and therefore parsable.
227-
this._limit = Number.parseInt(limitMatch[2], 10);
228-
} else {
241+
if (limitMatch === null) {
229242
this._error = 'do not understand query limit';
243+
return;
244+
}
245+
246+
// limitMatch[3] is per regex always digits and therefore parsable.
247+
const limitFromLine = Number.parseInt(limitMatch[3], 10);
248+
249+
if (limitMatch[1] !== undefined) {
250+
this._taskGroupLimit = limitFromLine;
251+
} else {
252+
this._limit = limitFromLine;
230253
}
231254
}
232255

src/Query/TaskGroup.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export class TaskGroup {
4949
* All the tasks that match the user's filters and that have the
5050
* group names exactly matching groups().
5151
*/
52-
public readonly tasks: Task[];
52+
public tasks: Task[];
5353

5454
/**
5555
* Constructor
@@ -68,6 +68,18 @@ export class TaskGroup {
6868
}
6969
}
7070

71+
/**
72+
* Limits {@link tasks} array to a certain number. Tasks exceeding
73+
* the limit will be removed from the end, shall be called on sorted tasks.
74+
*
75+
* @param limit number of tasks for the group to have. If greater
76+
* than the task count, no action will be taken.
77+
*
78+
*/
79+
public applyTaskLimit(limit: number) {
80+
this.tasks = this.tasks.slice(0, limit);
81+
}
82+
7183
/**
7284
* A markdown-format representation of all the tasks in this group.
7385
*

src/Query/TaskGroups.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,4 +133,37 @@ export class TaskGroups {
133133
group.setGroupHeadings(displayHeadingSelector.getHeadingsForTaskGroup(group.groups));
134134
}
135135
}
136+
137+
/**
138+
* Limits all {@link groups} to have a certain number of tasks and
139+
* recalculates the {@link _totalTaskCount} for consistency.
140+
*
141+
* If no `group by ...` instructions were provided the limit is ignored,
142+
* however there will be one task group.
143+
*
144+
* @param limit number of tasks for each group to have.
145+
*
146+
*/
147+
public applyTaskLimit(limit: number) {
148+
if (this._groupers.length === 0) {
149+
return;
150+
}
151+
152+
this._groups.forEach((group) => {
153+
group.applyTaskLimit(limit);
154+
});
155+
156+
this.calculateTotalTaskCount();
157+
}
158+
159+
private calculateTotalTaskCount() {
160+
let concatenatedTasks: Task[] = [];
161+
162+
this._groups.forEach((group) => {
163+
concatenatedTasks = [...concatenatedTasks, ...group.tasks];
164+
});
165+
166+
const uniqueTasks = [...new Set(concatenatedTasks)];
167+
this._totalTaskCount = uniqueTasks.length;
168+
}
136169
}

tests/Query.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,8 @@ describe('Query parsing', () => {
270270
'hide urgency',
271271
'limit 42',
272272
'limit to 42 tasks',
273+
'limit groups 31',
274+
'limit groups to 31 tasks',
273275
'short mode',
274276
'short',
275277
'show backlink',
@@ -873,6 +875,17 @@ At most 1 task.
873875
const expectedDisplayText = `No filters supplied. All tasks will match the query.
874876
875877
At most 0 tasks.
878+
`;
879+
expect(query.explainQuery()).toEqual(expectedDisplayText);
880+
});
881+
882+
it('should explain group limit 4', () => {
883+
const input = 'limit groups 4';
884+
const query = new Query({ source: input });
885+
886+
const expectedDisplayText = `No filters supplied. All tasks will match the query.
887+
888+
At most 4 tasks per group.
876889
`;
877890
expect(query.explainQuery()).toEqual(expectedDisplayText);
878891
});
@@ -948,5 +961,49 @@ At most 0 tasks.
948961
`;
949962
expect('\n' + soleTaskGroup.tasksAsStringOfLines()).toStrictEqual(expectedTasks);
950963
});
964+
965+
it('should apply group limit correctly, after sorting tasks', () => {
966+
// Arrange
967+
const input = `
968+
# sorting by description will sort the tasks alphabetically
969+
sort by description
970+
971+
# grouping by status will give two groups: Done and Todo
972+
group by status
973+
974+
# Apply a limit, to test which tasks make it to
975+
limit groups 3
976+
`;
977+
const query = new Query({ source: input });
978+
979+
const tasksAsMarkdown = `
980+
- [x] Task 2 - will be in the first group and sorted after next one
981+
- [x] Task 1 - will be in the first group
982+
- [ ] Task 4 - will be sorted to 2nd place in the second group and pass the limit
983+
- [ ] Task 6 - will be sorted to 4th place in the second group and NOT pass the limit
984+
- [ ] Task 3 - will be sorted to 1st place in the second group and pass the limit
985+
- [ ] Task 5 - will be sorted to 3nd place in the second group and pass the limit
986+
`;
987+
988+
const tasks = createTasksFromMarkdown(tasksAsMarkdown, 'some_markdown_file', 'Some Heading');
989+
990+
// Act
991+
const groups = query.applyQueryToTasks(tasks);
992+
993+
// Assert
994+
expect(groups.groups.length).toEqual(2);
995+
expect(groups.totalTasksCount()).toEqual(5);
996+
expect(groups.groups[0].tasksAsStringOfLines()).toMatchInlineSnapshot(`
997+
"- [x] Task 1 - will be in the first group
998+
- [x] Task 2 - will be in the first group and sorted after next one
999+
"
1000+
`);
1001+
expect(groups.groups[1].tasksAsStringOfLines()).toMatchInlineSnapshot(`
1002+
"- [ ] Task 3 - will be sorted to 1st place in the second group and pass the limit
1003+
- [ ] Task 4 - will be sorted to 2nd place in the second group and pass the limit
1004+
- [ ] Task 5 - will be sorted to 3nd place in the second group and pass the limit
1005+
"
1006+
`);
1007+
});
9511008
});
9521009
});

tests/TaskGroups.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,4 +344,123 @@ describe('Grouping tasks', () => {
344344
"
345345
`);
346346
});
347+
348+
it('should limit tasks in each group (no task overlapping across group)', () => {
349+
// Arrange
350+
const a = fromLine({ line: '- [ ] a', path: 'tasks_under_the_limit.md' });
351+
const b = fromLine({ line: '- [ ] b', path: 'tasks_equal_to_limit.md' });
352+
const c = fromLine({ line: '- [ ] c', path: 'tasks_equal_to_limit.md' });
353+
const d = fromLine({ line: '- [ ] d', path: 'tasks_over_the_limit.md' });
354+
const e = fromLine({ line: '- [ ] e', path: 'tasks_over_the_limit.md' });
355+
const f = fromLine({ line: '- [ ] f', path: 'tasks_over_the_limit.md' });
356+
const inputs = [a, b, c, d, e, f];
357+
358+
// Act
359+
const grouping = [new PathField().createNormalGrouper()];
360+
const groups = new TaskGroups(grouping, inputs);
361+
groups.applyTaskLimit(2);
362+
363+
// Assert
364+
expect(groups.totalTasksCount()).toEqual(5);
365+
expect(groups.toString()).toMatchInlineSnapshot(`
366+
"Groupers (if any):
367+
- path
368+
369+
Group names: [tasks\\_equal\\_to\\_limit]
370+
#### [path] tasks\\_equal\\_to\\_limit
371+
- [ ] b
372+
- [ ] c
373+
374+
---
375+
376+
Group names: [tasks\\_over\\_the\\_limit]
377+
#### [path] tasks\\_over\\_the\\_limit
378+
- [ ] d
379+
- [ ] e
380+
381+
---
382+
383+
Group names: [tasks\\_under\\_the\\_limit]
384+
#### [path] tasks\\_under\\_the\\_limit
385+
- [ ] a
386+
387+
---
388+
389+
5 tasks
390+
"
391+
`);
392+
});
393+
394+
it('should limit tasks with tasks that overlap across multiple groups and correctly calculate unique tasks', () => {
395+
// Arrange
396+
const taskA = fromLine({ line: '- [ ] task A #tag1 #tag2' });
397+
const taskB = fromLine({ line: '- [ ] task B #tag1 #tag3' });
398+
const taskC = fromLine({ line: '- [ ] task C #tag3 #tag2' });
399+
const taskD = fromLine({ line: '- [ ] task D #tag1 #tag2' });
400+
const inputs = [taskA, taskB, taskC, taskD];
401+
402+
// Act
403+
const grouping = [new TagsField().createNormalGrouper()];
404+
const groups = new TaskGroups(grouping, inputs);
405+
groups.applyTaskLimit(1);
406+
407+
// Assert
408+
expect(groups.totalTasksCount()).toEqual(2);
409+
expect(groups.toString()).toMatchInlineSnapshot(`
410+
"Groupers (if any):
411+
- tags
412+
413+
Group names: [#tag1]
414+
#### [tags] #tag1
415+
- [ ] task A #tag1 #tag2
416+
417+
---
418+
419+
Group names: [#tag2]
420+
#### [tags] #tag2
421+
- [ ] task A #tag1 #tag2
422+
423+
---
424+
425+
Group names: [#tag3]
426+
#### [tags] #tag3
427+
- [ ] task B #tag1 #tag3
428+
429+
---
430+
431+
2 tasks
432+
"
433+
`);
434+
});
435+
436+
it('should not limit tasks if no groups were specified', () => {
437+
// Arrange
438+
const taskA = fromLine({ line: '- [ ] task A' });
439+
const taskB = fromLine({ line: '- [ ] task B' });
440+
const taskC = fromLine({ line: '- [ ] task C' });
441+
const taskD = fromLine({ line: '- [ ] task D' });
442+
const inputs = [taskA, taskB, taskC, taskD];
443+
444+
// Act
445+
const grouping: Grouper[] = [];
446+
const groups = new TaskGroups(grouping, inputs);
447+
groups.applyTaskLimit(1);
448+
449+
// Assert
450+
expect(groups.totalTasksCount()).toEqual(4);
451+
expect(groups.toString()).toMatchInlineSnapshot(`
452+
"Groupers (if any):
453+
454+
Group names: []
455+
- [ ] task A
456+
- [ ] task B
457+
- [ ] task C
458+
- [ ] task D
459+
460+
---
461+
462+
4 tasks
463+
"
464+
`);
465+
});
347466
});

0 commit comments

Comments
 (0)