diff --git a/src/app/core-ui/navigate-to-task/navigate-to-task.service.spec.ts b/src/app/core-ui/navigate-to-task/navigate-to-task.service.spec.ts new file mode 100644 index 0000000000..ecfb2f1c66 --- /dev/null +++ b/src/app/core-ui/navigate-to-task/navigate-to-task.service.spec.ts @@ -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 => + ({ + 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; + let router: jasmine.SpyObj & { url: string }; + let layoutService: jasmine.SpyObj; + + 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(), + ); + }); +}); diff --git a/src/app/core-ui/navigate-to-task/navigate-to-task.service.ts b/src/app/core-ui/navigate-to-task/navigate-to-task.service.ts index 81ad3fefa6..ee1770d9ed 100644 --- a/src/app/core-ui/navigate-to-task/navigate-to-task.service.ts +++ b/src/app/core-ui/navigate-to-task/navigate-to-task.service.ts @@ -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 '';