Skip to content

Commit 4f4eb76

Browse files
authored
Merge pull request #3392 from obsidian-tasks-group/prevent-floating-point-groupnames
fix: Stable `group by function task.urgency` order
2 parents 691baa4 + 1bbcf75 commit 4f4eb76

File tree

8 files changed

+93
-3
lines changed

8 files changed

+93
-3
lines changed

docs/Queries/Grouping.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,15 @@ group by function task.urgency.toFixed(3)
663663

664664
- Show the urgency to 3 decimal places, unlike the built-in "group by urgency" which uses 2.
665665

666+
```javascript
667+
group by function task.urgency
668+
```
669+
670+
- Show non-integer urgency values to 5 decimal places, and integer ones to 0 decimal places.
671+
- Sorting of groups by name has been found to be unreliable with varying numbers of decimal places.
672+
- So to ensure consistent sorting, Tasks will round non-integer numbers to a fixed 5 decimal places, returning the value as a string.
673+
- This still sorts consistently even when some of the group's values are integers.
674+
666675
<!-- placeholder to force blank line after included text --><!-- endInclude -->
667676
668677
### Recurrence

docs/Scripting/Custom Grouping.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ publish: true
1313

1414
- Define your own custom task groups, using JavaScript expressions such as:
1515
- `group by function task.urgency.toFixed(3)`
16+
- See [[#Number property examples]] below for how floating point numbers are treated, if the precision (number of decimal places) is not specified.
1617
- There are loads of examples in [[Grouping]].
1718
- Search for `group by function` in that file.
1819
- Find all the **supported tasks properties** in [[Task Properties]] and [[Quick Reference]].
@@ -193,6 +194,15 @@ group by function task.urgency.toFixed(3)
193194
194195
- Show the urgency to 3 decimal places, unlike the built-in "group by urgency" which uses 2.
195196
197+
```javascript
198+
group by function task.urgency
199+
```
200+
201+
- Show non-integer urgency values to 5 decimal places, and integer ones to 0 decimal places.
202+
- Sorting of groups by name has been found to be unreliable with varying numbers of decimal places.
203+
- So to ensure consistent sorting, Tasks will round non-integer numbers to a fixed 5 decimal places, returning the value as a string.
204+
- This still sorts consistently even when some of the group's values are integers.
205+
196206
<!-- placeholder to force blank line after included text --><!-- endInclude -->
197207
198208
### File property examples

src/Query/Filter/FunctionField.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,14 @@ export function groupByFunction(task: Task, arg: GroupingArg, queryContext?: Que
283283
return [];
284284
}
285285

286+
if (typeof result === 'number' && !Number.isInteger(result)) {
287+
// Guard against #3371: order of groups, when grouping by "function task.urgency" without specifying precision, is confusing.
288+
// Sorting has been found to be unreliable with varying numbers of decimal places.
289+
// So to ensure consistent sorting, we round the value to a fixed number of decimals and return it as a string.
290+
// This still sorts consistently even when some of the group's values are integers.
291+
return [result.toFixed(5)];
292+
}
293+
286294
// If there was an error in the expression, like it referred to
287295
// an unknown task field, result will be undefined, and the call
288296
// on undefined.toString() will give an exception and a useful error

tests/Query/Filter/FunctionField.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -522,7 +522,7 @@ describe('FunctionField - grouping return types', () => {
522522
['1', ['1']],
523523
['0', ['0']],
524524
['0 || "No value"', ['No value']],
525-
['1.0765456', ['1.0765456']],
525+
['1.0765456', ['1.07655']],
526526
['1.0765456.toFixed(3)', ['1.077']],
527527
['["heading1", "heading2"]', ['heading1', 'heading2']], // return two headings, indicating that this task should be displayed twice, once in each heading
528528
['[1, 2]', ['1', '2']], // return two headings, that need to be converted to strings

tests/Query/Group/TaskGroups.test.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,23 @@ import { StatusTypeField } from '../../../src/Query/Filter/StatusTypeField';
1313
import { HappensDateField } from '../../../src/Query/Filter/HappensDateField';
1414
import { DueDateField } from '../../../src/Query/Filter/DueDateField';
1515
import { SearchInfo } from '../../../src/Query/SearchInfo';
16-
import { fromLine } from '../../TestingTools/TestHelpers';
16+
import { fromLine, fromLines } from '../../TestingTools/TestHelpers';
1717
import { TaskBuilder } from '../../TestingTools/TaskBuilder';
1818
import { TasksFile } from '../../../src/Scripting/TasksFile';
19+
import { FunctionField } from '../../../src/Query/Filter/FunctionField';
20+
import type { TaskGroup } from '../../../src/Query/Group/TaskGroup';
1921

2022
window.moment = moment;
2123

22-
function makeTasksGroups(grouping: Grouper[], inputs: Task[]): any {
24+
function makeTasksGroups(grouping: Grouper[], inputs: Task[]): TaskGroups {
2325
return new TaskGroups(grouping, inputs, SearchInfo.fromAllTasks(inputs));
2426
}
2527

28+
beforeEach(() => {});
29+
30+
afterEach(() => {
31+
jest.useRealTimers();
32+
});
2633
describe('Grouping tasks', () => {
2734
it('groups correctly by path', () => {
2835
// Arrange
@@ -231,6 +238,31 @@ describe('Grouping tasks', () => {
231238
`);
232239
});
233240

241+
it('sorts raw urgency value groups correctly', () => {
242+
jest.useFakeTimers();
243+
jest.setSystemTime(new Date('2025-03-07'));
244+
245+
const lines = [
246+
'- [ ] 0 📅 2025-02-28 🔺 ', // urgency: 21
247+
'- [ ] 1 📅 2025-03-07 ⏳ 2025-03-07', // urgency: 15.75
248+
'- [ ] 2 📅 2025-03-08 ⏳ 2025-03-07', // urgency: 15.292857142857141
249+
'- [ ] 3 📅 2025-03-09 ⏳ 2025-03-07', // urgency: 14.835714285714285
250+
'- [ ] 4 ⏫ 🛫 2025-03-18 ', // urgency: 3
251+
'- [ ] 5 🔽 ', // urgency: 0
252+
];
253+
const tasks = fromLines({ lines });
254+
255+
// See issue https://github.com/obsidian-tasks-group/obsidian-tasks/issues/3371
256+
// order of groups, when grouping by "function task.urgency" without specifying precision, is confusing
257+
// There is a problem with the sorting of groups whose floating-point values differ in precision,
258+
// when grouping by the raw urgency value
259+
const grouping = [new FunctionField().createGrouperFromLine('group by function task.urgency')!];
260+
261+
const groups: TaskGroups = makeTasksGroups(grouping, tasks);
262+
const groupHeadings = groups.groups.map((group: TaskGroup) => group.groups[0]);
263+
expect(groupHeadings).toEqual(['0', '3', '14.83571', '15.29286', '15.75000', '21']);
264+
});
265+
234266
it('handles tasks matching multiple groups correctly', () => {
235267
const a = fromLine({
236268
line: '- [ ] Task 1 #group1',

tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.other_properties_task.urgency_docs.approved.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,14 @@ group by function task.urgency.toFixed(3)
77

88
- Show the urgency to 3 decimal places, unlike the built-in "group by urgency" which uses 2.
99

10+
```javascript
11+
group by function task.urgency
12+
```
13+
14+
- Show non-integer urgency values to 5 decimal places, and integer ones to 0 decimal places.
15+
- Sorting of groups by name has been found to be unreliable with varying numbers of decimal places.
16+
- So to ensure consistent sorting, Tasks will round non-integer numbers to a fixed 5 decimal places, returning the value as a string.
17+
- This still sorts consistently even when some of the group's values are integers.
18+
1019

1120
<!-- placeholder to force blank line after included text -->

tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.other_properties_task.urgency_results.approved.txt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,18 @@ Show the urgency to 3 decimal places, unlike the built-in "group by urgency" whi
1313
9.000
1414
====================================================================================
1515

16+
17+
group by function task.urgency
18+
Show non-integer urgency values to 5 decimal places, and integer ones to 0 decimal places.
19+
Sorting of groups by name has been found to be unreliable with varying numbers of decimal places.
20+
So to ensure consistent sorting, Tasks will round non-integer numbers to a fixed 5 decimal places, returning the value as a string.
21+
This still sorts consistently even when some of the group's values are integers.
22+
=>
23+
-1.80000
24+
0
25+
1.95000
26+
3.90000
27+
6
28+
9
29+
====================================================================================
30+

tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,13 @@ describe('other properties', () => {
704704
'group by function task.urgency.toFixed(3)',
705705
'Show the urgency to 3 decimal places, unlike the built-in "group by urgency" which uses 2',
706706
],
707+
[
708+
'group by function task.urgency',
709+
'Show non-integer urgency values to 5 decimal places, and integer ones to 0 decimal places',
710+
'Sorting of groups by name has been found to be unreliable with varying numbers of decimal places.',
711+
'So to ensure consistent sorting, Tasks will round non-integer numbers to a fixed 5 decimal places, returning the value as a string.',
712+
"This still sorts consistently even when some of the group's values are integers.",
713+
],
707714
],
708715
SampleTasks.withAllPriorities(),
709716
],

0 commit comments

Comments
 (0)