fix(tasks): navigate to Today for context-less tasks from search (#8780) (#8801)

A non-archived task with no project, no tags, and not due today resolved
to an empty location in NavigateToTaskService. Because ''.startsWith is
always true, navigate() treated it as the current context and only tried
to focus a task element that isn't on the /search page, so tapping the
search result did nothing (most commonly an overdue Today/Inbox task).

Route such tasks to the Today list (where overdue/context-less tasks are
shown) and guard against an empty location so it surfaces an error snack
instead of silently swallowing the navigation.
This commit is contained in:
Johannes Millan 2026-07-06 19:11:51 +02:00 committed by GitHub
parent 462fb616c9
commit 3399491f75
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 122 additions and 0 deletions

View file

@ -0,0 +1,111 @@
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { NavigateToTaskService } from './navigate-to-task.service';
import { TaskService } from '../../features/tasks/task.service';
import { ProjectService } from '../../features/project/project.service';
import { SnackService } from '../../core/snack/snack.service';
import { DateService } from '../../core/date/date.service';
import { LayoutService } from '../layout/layout.service';
import { Task } from '../../features/tasks/task.model';
import { TODAY_TAG } from '../../features/tag/tag.const';
import { of } from 'rxjs';
const TODAY_STR = '2026-07-06';
const createTask = (partial: Partial<Task>): Task =>
({
id: 'task-1',
title: 'A task',
projectId: undefined,
parentId: undefined,
tagIds: [],
dueDay: undefined,
dueWithTime: undefined,
timeSpentOnDay: {},
created: Date.parse('2020-01-01'),
...partial,
}) as unknown as Task;
describe('NavigateToTaskService', () => {
let service: NavigateToTaskService;
let taskService: jasmine.SpyObj<TaskService>;
let router: jasmine.SpyObj<Router> & { url: string };
let layoutService: jasmine.SpyObj<LayoutService>;
beforeEach(() => {
taskService = jasmine.createSpyObj('TaskService', [
'getByIdFromEverywhere',
'getArchivedTasks',
]);
const projectService = jasmine.createSpyObj('ProjectService', [], {
list$: of([]),
});
const snackService = jasmine.createSpyObj('SnackService', ['open']);
const dateService = jasmine.createSpyObj('DateService', ['isToday', 'todayStr']);
dateService.todayStr.and.returnValue(TODAY_STR);
dateService.isToday.and.returnValue(false);
layoutService = jasmine.createSpyObj('LayoutService', ['focusTaskInViewWhenReady']);
const routerSpy = jasmine.createSpyObj('Router', ['navigate']);
routerSpy.navigate.and.resolveTo(true);
routerSpy.url = '/search';
router = routerSpy;
TestBed.configureTestingModule({
providers: [
NavigateToTaskService,
{ provide: TaskService, useValue: taskService },
{ provide: ProjectService, useValue: projectService },
{ provide: SnackService, useValue: snackService },
{ provide: DateService, useValue: dateService },
{ provide: LayoutService, useValue: layoutService },
{ provide: Router, useValue: router },
],
});
service = TestBed.inject(NavigateToTaskService);
});
it('navigates to the Today list for an overdue task with no project and no tags (#8780)', async () => {
// Overdue = dueDay in the past → shown only in the Today "Overdue" section.
taskService.getByIdFromEverywhere.and.resolveTo(
createTask({ id: 't1', dueDay: '2020-01-01', projectId: undefined, tagIds: [] }),
);
await service.navigate('t1');
expect(router.navigate).toHaveBeenCalledWith(
[`/tag/${TODAY_TAG.id}/tasks`],
jasmine.objectContaining({
queryParams: jasmine.objectContaining({ focusItem: 't1' }),
}),
);
// Must NOT be short-circuited into the "same context" focus-only path.
expect(layoutService.focusTaskInViewWhenReady).not.toHaveBeenCalled();
});
it('navigates to the project list for a project task', async () => {
taskService.getByIdFromEverywhere.and.resolveTo(
createTask({ id: 't2', projectId: 'p1', tagIds: [] }),
);
await service.navigate('t2');
expect(router.navigate).toHaveBeenCalledWith(
['/project/p1/tasks'],
jasmine.anything(),
);
});
it('navigates to the tag list for a tagged task with no project', async () => {
taskService.getByIdFromEverywhere.and.resolveTo(
createTask({ id: 't3', projectId: undefined, tagIds: ['tag-a'] }),
);
await service.navigate('t3');
expect(router.navigate).toHaveBeenCalledWith(
['/tag/tag-a/tasks'],
jasmine.anything(),
);
});
});

View file

@ -33,6 +33,11 @@ export class NavigateToTaskService {
throw new Error(`Task with id ${taskId} not found`);
}
const location = await this._getLocation(task, isArchiveTask);
if (!location) {
// Never fall through with an empty location: `''.startsWith` would make
// the same-context check below always true and swallow the navigation.
throw new Error(`Could not resolve a location for task ${taskId}`);
}
recordSearchNavDebug('navigateToTask:start', {
taskId,
isArchiveTask,
@ -101,6 +106,12 @@ export class NavigateToTaskService {
return `/project/${taskToCheck.projectId}/${tasksOrWorklog}`;
} else if (taskToCheck.tagIds?.length > 0 && taskToCheck.tagIds[0]) {
return `/tag/${taskToCheck.tagIds[0]}/${tasksOrWorklog}`;
} else if (!isArchiveTask) {
// A non-archived task with neither project nor tags only ever lives in the
// Today list — either due today (handled above) or overdue. Route there so
// navigation reveals it instead of resolving to '' and silently no-op'ing
// (an empty location makes `url.startsWith(location)` always true). (#8780)
return `/tag/${TODAY_TAG.id}/${tasksOrWorklog}`;
} else {
devError("Couldn't find task location");
return '';