feat(tasks): optional setting to sort completed tasks by completion date (#8810)

* feat(tasks): optional setting to sort completed tasks by completion date

Adds an opt-in "Sort completed tasks by completion date (newest first)"
setting under Settings > Tasks. When enabled, the Done task list is ordered
by each task's doneOn timestamp descending; when off (the default) behaviour
is unchanged. Sorting is applied at the doneTasks$ choke point so it covers
the Today list and project/tag contexts alike.

* refactor(tasks): make completion-date sort the calm default, not a toggle

Builds on the previous commit's opt-in setting. Ordering the Done list by
completion date (newest first) is the expected default for reviewing just-
finished work (issue #2936), and no manual done-order is preserved anyway,
so drop the toggle and apply the sort unconditionally at doneTasks$. Removes
the config field + default, the settings-form checkbox, and the T/en.json
label; moves the pure helper to work-context.util.ts with its own spec
(stable-sort and empty-array cases included).

Also guards _move so a reorder WITHIN the now-auto-sorted Done list no longer
emits a spurious moveTaskInTodayList op that would sync to other devices;
dragging a task out of Done (DONE -> UNDONE) still works. Specs cover both.

* fix(tasks): skip keyboard reorder of auto-sorted done tasks

Complements the drag guard from the previous commit. The move up/down/top/
bottom shortcuts on a top-level done task in the main list dispatched a
taskIds-reorder that the completion-date-sorted Done list ignores, emitting
a spurious op that syncs to other devices. Guard _moveAndRefocus (the single
choke point for all four keyboard reorders) so it no-ops for done main-list
tasks. Done subtasks and backlog tasks stay reorderable — neither is
auto-sorted.

---------

Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
This commit is contained in:
Myk 2026-07-07 17:32:53 +02:00 committed by GitHub
parent 3e56836a23
commit c330ab1d7b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 91 additions and 2 deletions

View file

@ -716,6 +716,24 @@ describe('TaskListComponent', () => {
expect(sectionServiceMock.addTaskToSection).not.toHaveBeenCalled();
expect(sectionServiceMock.removeTaskFromSection).not.toHaveBeenCalled();
});
it('skips a reorder WITHIN the Done list (auto-sorted) so no moveTaskInTodayList op is emitted', () => {
callMove('task1', 'DONE', 'DONE', 'PARENT', 'PARENT');
const dispatched = (store.dispatch as jasmine.Spy).calls
.allArgs()
.map((args) => args[0]);
expect(dispatched.some((a) => a.type === moveTaskInTodayList.type)).toBe(false);
});
it('still moves a task OUT of Done (DONE -> UNDONE) via moveTaskInTodayList', () => {
callMove('task1', 'DONE', 'UNDONE', 'PARENT', 'PARENT');
const dispatched = (store.dispatch as jasmine.Spy).calls
.allArgs()
.map((args) => args[0]);
expect(dispatched.some((a) => a.type === moveTaskInTodayList.type)).toBe(true);
});
});
// The public drop() handler turns a CdkDragDrop event into the right action,

View file

@ -557,6 +557,15 @@ export class TaskListComponent implements OnDestroy, AfterViewInit {
return;
}
// The Done list is always ordered by completion date, so reordering within
// it can't take effect (the view re-sorts on the next emission). Skip the
// move to avoid emitting a spurious taskIds-reorder op that would sync to
// other devices. Dragging a task OUT of Done (DONE -> UNDONE) still falls
// through below.
if (src === 'DONE' && target === 'DONE') {
return;
}
if (
srcListId === 'SUB' &&
targetListId === 'PARENT' &&

View file

@ -699,6 +699,13 @@ export class TaskComponent implements OnDestroy, AfterViewInit {
move: (id: string, parentId: string | undefined, isBacklog: boolean) => void,
): void {
const t = this.task();
// Top-level done tasks in the main list are ordered by completion date, so
// a manual reorder can't take effect — skip it to avoid emitting a spurious
// taskIds-reorder op that would sync to other devices. Done subtasks and
// backlog tasks are not auto-sorted, so they stay reorderable.
if (t.isDone && !t.parentId && !this.isBacklog()) {
return;
}
move(t.id, t.parentId, this.isBacklog());
// timeout required to let changes take place
setTimeout(() => this.focusSelf());

View file

@ -31,7 +31,11 @@ import { Tag } from '../tag/tag.model';
import { DEFAULT_TAG_COLOR } from './work-context.const';
import { TagService } from '../tag/tag.service';
import { ArchiveTask, Task, TaskWithSubTasks } from '../tasks/task.model';
import { hasTasksToWorkOn, mapEstimateRemainingFromTasks } from './work-context.util';
import {
hasTasksToWorkOn,
mapEstimateRemainingFromTasks,
sortDoneTasksByDoneDate,
} from './work-context.util';
import {
flattenTasks,
selectAllTasks,
@ -441,7 +445,9 @@ export class WorkContextService {
switchMap((isToday) =>
isToday ? this._store$.select(selectAllTasksWithSubTasks) : this.mainListTasks$,
),
map((tasks) => tasks.filter((task) => task && task.isDone)),
// Show completed tasks newest-first (by completion time) so the task you
// just finished is at the top of the Done list.
map((tasks) => sortDoneTasksByDoneDate(tasks.filter((task) => task && task.isDone))),
);
constructor() {

View file

@ -0,0 +1,40 @@
import { sortDoneTasksByDoneDate } from './work-context.util';
import { TaskWithSubTasks } from '../tasks/task.model';
describe('sortDoneTasksByDoneDate', () => {
const task = (id: string, doneOn?: number): TaskWithSubTasks =>
({ id, isDone: true, doneOn }) as TaskWithSubTasks;
it('orders completed tasks by completion date, newest first', () => {
const sorted = sortDoneTasksByDoneDate([
task('OLD', 1000),
task('NEW', 3000),
task('MID', 2000),
]);
expect(sorted.map((t) => t.id)).toEqual(['NEW', 'MID', 'OLD']);
});
it('treats a missing completion timestamp as oldest', () => {
const sorted = sortDoneTasksByDoneDate([task('NO_DATE'), task('HAS_DATE', 5000)]);
expect(sorted.map((t) => t.id)).toEqual(['HAS_DATE', 'NO_DATE']);
});
it('does not mutate the input array', () => {
const input = [task('A', 1000), task('B', 2000)];
sortDoneTasksByDoneDate(input);
expect(input.map((t) => t.id)).toEqual(['A', 'B']);
});
it('preserves input order for equal completion timestamps (stable sort)', () => {
const sorted = sortDoneTasksByDoneDate([
task('A', 1000),
task('B', 1000),
task('C', 1000),
]);
expect(sorted.map((t) => t.id)).toEqual(['A', 'B', 'C']);
});
it('returns an empty array unchanged', () => {
expect(sortDoneTasksByDoneDate([])).toEqual([]);
});
});

View file

@ -1,5 +1,14 @@
import { TaskWithSubTasks } from '../tasks/task.model';
/**
* Order completed tasks by when they were completed, newest first, so the most
* recently finished task is at the top of the Done list. Copies the input since
* it usually comes from a memoized selector that must not be mutated. Tasks
* without a `doneOn` timestamp sort last.
*/
export const sortDoneTasksByDoneDate = (tasks: TaskWithSubTasks[]): TaskWithSubTasks[] =>
[...tasks].sort((a, b) => (b.doneOn ?? 0) - (a.doneOn ?? 0));
export const mapEstimateRemainingFromTasks = (tasks: TaskWithSubTasks[]): number =>
tasks &&
tasks.length &&