From c2b76271258c0ae92fede4b0285f144eb8437ef9 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Mon, 5 Jan 2026 18:26:57 +0100 Subject: [PATCH] fix(calendar): poll all calendar tasks and prevent auto-move of existing tasks - Poll ALL calendar tasks across all projects, not just current context - Replace forkJoin with merge (forkJoin never emits with timer) - Add selectAllCalendarIssueTasks selector for cross-project calendar tasks - Prevent existing ICAL tasks from being auto-moved to current context - Add error handling to prevent polling stream termination on errors - Add comprehensive tests for polling effects and selector Fixes #4474 --- src/app/features/issue/issue.service.spec.ts | 307 +++++++++ src/app/features/issue/issue.service.ts | 15 + .../store/poll-issue-updates.effects.spec.ts | 591 +++++++++++++++++- .../issue/store/poll-issue-updates.effects.ts | 125 ++-- .../tasks/store/task.selectors.spec.ts | 82 +++ .../features/tasks/store/task.selectors.ts | 5 + src/app/t.const.ts | 1 + src/assets/i18n/en.json | 1 + 8 files changed, 1063 insertions(+), 64 deletions(-) create mode 100644 src/app/features/issue/issue.service.spec.ts diff --git a/src/app/features/issue/issue.service.spec.ts b/src/app/features/issue/issue.service.spec.ts new file mode 100644 index 000000000..aff313cc4 --- /dev/null +++ b/src/app/features/issue/issue.service.spec.ts @@ -0,0 +1,307 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { IssueService } from './issue.service'; +import { TaskService } from '../tasks/task.service'; +import { SnackService } from '../../core/snack/snack.service'; +import { WorkContextService } from '../work-context/work-context.service'; +import { WorkContextType } from '../work-context/work-context.model'; +import { IssueProviderService } from './issue-provider.service'; +import { ProjectService } from '../project/project.service'; +import { CalendarIntegrationService } from '../calendar-integration/calendar-integration.service'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { GlobalProgressBarService } from '../../core-ui/global-progress-bar/global-progress-bar.service'; +import { NavigateToTaskService } from '../../core-ui/navigate-to-task/navigate-to-task.service'; +import { Task, TaskWithSubTasks } from '../tasks/task.model'; +import { of } from 'rxjs'; +import { T } from '../../t.const'; +import { TODAY_TAG } from '../tag/tag.const'; +import { ICalIssueReduced } from './providers/calendar/calendar.model'; +import { SnackParams } from '../../core/snack/snack.model'; +import { JiraCommonInterfacesService } from './providers/jira/jira-common-interfaces.service'; +import { GithubCommonInterfacesService } from './providers/github/github-common-interfaces.service'; +import { TrelloCommonInterfacesService } from './providers/trello/trello-common-interfaces.service'; +import { GitlabCommonInterfacesService } from './providers/gitlab/gitlab-common-interfaces.service'; +import { CaldavCommonInterfacesService } from './providers/caldav/caldav-common-interfaces.service'; +import { OpenProjectCommonInterfacesService } from './providers/open-project/open-project-common-interfaces.service'; +import { GiteaCommonInterfacesService } from './providers/gitea/gitea-common-interfaces.service'; +import { RedmineCommonInterfacesService } from './providers/redmine/redmine-common-interfaces.service'; +import { LinearCommonInterfacesService } from './providers/linear/linear-common-interfaces.service'; +import { ClickUpCommonInterfacesService } from './providers/clickup/clickup-common-interfaces.service'; +import { CalendarCommonInterfacesService } from './providers/calendar/calendar-common-interfaces.service'; + +describe('IssueService', () => { + let service: IssueService; + let taskServiceSpy: jasmine.SpyObj; + let snackServiceSpy: jasmine.SpyObj; + let workContextServiceSpy: jasmine.SpyObj; + let issueProviderServiceSpy: jasmine.SpyObj; + let projectServiceSpy: jasmine.SpyObj; + let calendarIntegrationServiceSpy: jasmine.SpyObj; + let storeSpy: jasmine.SpyObj; + let translateServiceSpy: jasmine.SpyObj; + let globalProgressBarServiceSpy: jasmine.SpyObj; + let navigateToTaskServiceSpy: jasmine.SpyObj; + + const createMockTask = (overrides: Partial = {}): Task => + ({ + id: 'existing-task-123', + title: 'Existing Calendar Event Task', + issueId: 'cal-event-456', + issueProviderId: 'calendar-provider-1', + issueType: 'ICAL', + dueWithTime: new Date('2025-01-20T14:00:00Z').getTime(), + projectId: 'project-1', + tagIds: [], + ...overrides, + }) as Task; + + const createMockCalendarEvent = ( + overrides: Partial = {}, + ): ICalIssueReduced => ({ + id: 'cal-event-456', + calProviderId: 'calendar-provider-1', + title: 'Calendar Event', + start: new Date('2025-01-20T14:00:00Z').getTime(), + duration: 3600000, + ...overrides, + }); + + beforeEach(() => { + taskServiceSpy = jasmine.createSpyObj('TaskService', [ + 'checkForTaskWithIssueEverywhere', + 'getByIdWithSubTaskData$', + 'moveToCurrentWorkContext', + 'add', + 'addAndSchedule', + 'restoreTask', + ]); + snackServiceSpy = jasmine.createSpyObj('SnackService', ['open']); + workContextServiceSpy = jasmine.createSpyObj('WorkContextService', [], { + activeWorkContextId: TODAY_TAG.id, + activeWorkContextType: WorkContextType.TAG, + }); + issueProviderServiceSpy = jasmine.createSpyObj('IssueProviderService', [ + 'getCfgOnce$', + ]); + projectServiceSpy = jasmine.createSpyObj('ProjectService', [ + 'getByIdOnce$', + 'moveTaskToTodayList', + ]); + calendarIntegrationServiceSpy = jasmine.createSpyObj('CalendarIntegrationService', [ + 'skipCalendarEvent', + ]); + storeSpy = jasmine.createSpyObj('Store', ['select', 'dispatch']); + translateServiceSpy = jasmine.createSpyObj('TranslateService', ['instant']); + globalProgressBarServiceSpy = jasmine.createSpyObj('GlobalProgressBarService', [ + 'countUp', + 'countDown', + ]); + navigateToTaskServiceSpy = jasmine.createSpyObj('NavigateToTaskService', [ + 'navigate', + ]); + + // Default mock return values - use 'as any' to bypass strict type checking + issueProviderServiceSpy.getCfgOnce$.and.returnValue( + of({ defaultProjectId: 'project-1' } as any), + ); + + // Default mock for getByIdWithSubTaskData$ - needed when task already exists + taskServiceSpy.getByIdWithSubTaskData$.and.returnValue( + of({ + id: 'existing-task-123', + title: 'Existing Task', + subTasks: [], + } as any), + ); + + // Default mock for projectService + projectServiceSpy.getByIdOnce$.and.returnValue(of({ title: 'Project 1' } as any)); + + // Create mock providers for all common interface services + const mockCommonInterfaceService = jasmine.createSpyObj('CommonInterfaceService', [ + 'isEnabled', + 'getAddTaskData', + ]); + + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + IssueService, + { provide: TaskService, useValue: taskServiceSpy }, + { provide: SnackService, useValue: snackServiceSpy }, + { provide: WorkContextService, useValue: workContextServiceSpy }, + { provide: IssueProviderService, useValue: issueProviderServiceSpy }, + { provide: ProjectService, useValue: projectServiceSpy }, + { provide: CalendarIntegrationService, useValue: calendarIntegrationServiceSpy }, + { provide: Store, useValue: storeSpy }, + { provide: TranslateService, useValue: translateServiceSpy }, + { provide: GlobalProgressBarService, useValue: globalProgressBarServiceSpy }, + { provide: NavigateToTaskService, useValue: navigateToTaskServiceSpy }, + { provide: JiraCommonInterfacesService, useValue: mockCommonInterfaceService }, + { provide: GithubCommonInterfacesService, useValue: mockCommonInterfaceService }, + { provide: TrelloCommonInterfacesService, useValue: mockCommonInterfaceService }, + { provide: GitlabCommonInterfacesService, useValue: mockCommonInterfaceService }, + { provide: CaldavCommonInterfacesService, useValue: mockCommonInterfaceService }, + { + provide: OpenProjectCommonInterfacesService, + useValue: mockCommonInterfaceService, + }, + { provide: GiteaCommonInterfacesService, useValue: mockCommonInterfaceService }, + { provide: RedmineCommonInterfacesService, useValue: mockCommonInterfaceService }, + { provide: LinearCommonInterfacesService, useValue: mockCommonInterfaceService }, + { provide: ClickUpCommonInterfacesService, useValue: mockCommonInterfaceService }, + { + provide: CalendarCommonInterfacesService, + useValue: mockCommonInterfaceService, + }, + ], + }); + service = TestBed.inject(IssueService); + }); + + describe('addTaskFromIssue - ICAL task already exists', () => { + it('should NOT move existing ICAL task to current context when task already exists', async () => { + const existingTask = createMockTask(); + const calendarEvent = createMockCalendarEvent(); + + // Task already exists - checkForTaskWithIssueEverywhere returns the task + taskServiceSpy.checkForTaskWithIssueEverywhere.and.resolveTo({ + task: existingTask, + subTasks: null, + isFromArchive: false, + }); + + await service.addTaskFromIssue({ + issueDataReduced: calendarEvent, + issueProviderId: 'calendar-provider-1', + issueProviderKey: 'ICAL', + }); + + // Should NOT call moveToCurrentWorkContext - this is the key assertion + expect(taskServiceSpy.moveToCurrentWorkContext).not.toHaveBeenCalled(); + }); + + it('should show snackbar with Go to Task action when ICAL task already exists', async () => { + const existingTask = createMockTask(); + const calendarEvent = createMockCalendarEvent(); + + taskServiceSpy.checkForTaskWithIssueEverywhere.and.resolveTo({ + task: existingTask, + subTasks: null, + isFromArchive: false, + }); + + await service.addTaskFromIssue({ + issueDataReduced: calendarEvent, + issueProviderId: 'calendar-provider-1', + issueProviderKey: 'ICAL', + }); + + // Should show snackbar with task title and Go to Task action + expect(snackServiceSpy.open).toHaveBeenCalledWith( + jasmine.objectContaining({ + msg: T.F.TASK.S.TASK_ALREADY_EXISTS, + actionStr: T.F.TASK.S.GO_TO_TASK, + actionFn: jasmine.any(Function), + }), + ); + }); + + it('should navigate to task when Go to Task action is clicked', async () => { + const existingTask = createMockTask(); + const calendarEvent = createMockCalendarEvent(); + + taskServiceSpy.checkForTaskWithIssueEverywhere.and.resolveTo({ + task: existingTask, + subTasks: null, + isFromArchive: false, + }); + + await service.addTaskFromIssue({ + issueDataReduced: calendarEvent, + issueProviderId: 'calendar-provider-1', + issueProviderKey: 'ICAL', + }); + + // Get the actionFn from the snackbar call and execute it + const snackCall = snackServiceSpy.open.calls.mostRecent(); + const snackParams = snackCall.args[0] as SnackParams; + const actionFn = snackParams.actionFn; + actionFn!(); + + expect(navigateToTaskServiceSpy.navigate).toHaveBeenCalledWith( + existingTask.id, + false, + ); + }); + + it('should preserve original dueWithTime when ICAL task already exists', async () => { + const originalDueWithTime = new Date('2025-01-25T10:00:00Z').getTime(); + const existingTask = createMockTask({ dueWithTime: originalDueWithTime }); + const calendarEvent = createMockCalendarEvent(); + + taskServiceSpy.checkForTaskWithIssueEverywhere.and.resolveTo({ + task: existingTask, + subTasks: null, + isFromArchive: false, + }); + + await service.addTaskFromIssue({ + issueDataReduced: calendarEvent, + issueProviderId: 'calendar-provider-1', + issueProviderKey: 'ICAL', + }); + + // Should not modify the task at all - no moveToCurrentWorkContext + expect(taskServiceSpy.moveToCurrentWorkContext).not.toHaveBeenCalled(); + }); + + it('should return undefined when ICAL task already exists', async () => { + const existingTask = createMockTask(); + const calendarEvent = createMockCalendarEvent(); + + taskServiceSpy.checkForTaskWithIssueEverywhere.and.resolveTo({ + task: existingTask, + subTasks: null, + isFromArchive: false, + }); + + const result = await service.addTaskFromIssue({ + issueDataReduced: calendarEvent, + issueProviderId: 'calendar-provider-1', + issueProviderKey: 'ICAL', + }); + + expect(result).toBeUndefined(); + }); + }); + + describe('addTaskFromIssue - non-ICAL issue types (unchanged behavior)', () => { + it('should still move non-ICAL tasks to current context when found', async () => { + const existingTask = createMockTask({ issueType: 'GITHUB' }); + const githubIssue = { + id: 'github-issue-123', + title: 'GitHub Issue', + }; + + taskServiceSpy.checkForTaskWithIssueEverywhere.and.resolveTo({ + task: existingTask, + subTasks: null, + isFromArchive: false, + }); + taskServiceSpy.getByIdWithSubTaskData$.and.returnValue( + of(existingTask as TaskWithSubTasks), + ); + + await service.addTaskFromIssue({ + issueDataReduced: githubIssue as any, + issueProviderId: 'github-provider-1', + issueProviderKey: 'GITHUB', + }); + + // For non-ICAL types, should still call moveToCurrentWorkContext + expect(taskServiceSpy.moveToCurrentWorkContext).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/features/issue/issue.service.ts b/src/app/features/issue/issue.service.ts index 05251e7f4..2671850cf 100644 --- a/src/app/features/issue/issue.service.ts +++ b/src/app/features/issue/issue.service.ts @@ -58,6 +58,7 @@ import { getDbDateStr } from '../../util/get-db-date-str'; import { TODAY_TAG } from '../tag/tag.const'; import typia from 'typia'; import { GlobalProgressBarService } from '../../core-ui/global-progress-bar/global-progress-bar.service'; +import { NavigateToTaskService } from '../../core-ui/navigate-to-task/navigate-to-task.service'; @Injectable({ providedIn: 'root', @@ -83,6 +84,7 @@ export class IssueService { private _calendarIntegrationService = inject(CalendarIntegrationService); private _store = inject(Store); private _globalProgressBarService = inject(GlobalProgressBarService); + private _navigateToTaskService = inject(NavigateToTaskService); ISSUE_SERVICE_MAP: { [key: string]: IssueServiceInterface } = { [GITLAB_TYPE]: this._gitlabCommonInterfacesService, @@ -662,6 +664,19 @@ export class IssueService { translateParams: { title: res.task.title }, }); return true; + } else if (issueType === ICAL_TYPE) { + // For calendar events, don't move to today - just show snackbar with navigation + const taskId = res.task.id; + this._snackService.open({ + ico: 'info', + msg: T.F.TASK.S.TASK_ALREADY_EXISTS, + translateParams: { title: res.task.title }, + actionStr: T.F.TASK.S.GO_TO_TASK, + actionFn: () => { + this._navigateToTaskService.navigate(taskId, false); + }, + }); + return true; } else { const taskWithTaskSubTasks = await this._taskService .getByIdWithSubTaskData$(res.task.id) diff --git a/src/app/features/issue/store/poll-issue-updates.effects.spec.ts b/src/app/features/issue/store/poll-issue-updates.effects.spec.ts index 29e802850..6ed65f055 100644 --- a/src/app/features/issue/store/poll-issue-updates.effects.spec.ts +++ b/src/app/features/issue/store/poll-issue-updates.effects.spec.ts @@ -1,25 +1,566 @@ -// import { TestBed } from '@angular/core/testing'; -// import { provideMockActions } from '@ngrx/effects/testing'; -// import { Observable } from 'rxjs'; -// -// import { PollIssueUpdatesEffects } from './poll-issue-updates.effects'; -// -// describe('PollIssueUpdatesEffects', () => { -// let actions$: Observable; -// let effects: PollIssueUpdatesEffects; -// -// beforeEach(() => { -// TestBed.configureTestingModule({ -// providers: [ -// PollIssueUpdatesEffects, -// provideMockActions(() => actions$) -// ] -// }); -// -// effects = TestBed.inject(PollIssueUpdatesEffects); -// }); -// -// it('should be created', () => { -// expect(effects).toBeTruthy(); -// }); -// }); +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { Observable, of, Subject } from 'rxjs'; +import { PollIssueUpdatesEffects } from './poll-issue-updates.effects'; +import { IssueService } from '../issue.service'; +import { WorkContextService } from '../../work-context/work-context.service'; +import { WorkContextType } from '../../work-context/work-context.model'; +import { setActiveWorkContext } from '../../work-context/store/work-context.actions'; +import { loadAllData } from '../../../root-store/meta/load-all-data.action'; +import { selectEnabledIssueProviders } from './issue-provider.selectors'; +import { selectAllCalendarIssueTasks } from '../../tasks/store/task.selectors'; +import { ICAL_TYPE, GITHUB_TYPE, JIRA_TYPE } from '../issue.const'; +import { Task, TaskWithSubTasks } from '../../tasks/task.model'; +import { IssueProvider } from '../issue.model'; + +describe('PollIssueUpdatesEffects', () => { + let effects: PollIssueUpdatesEffects; + let actions$: Observable; + let store: MockStore; + let issueServiceSpy: jasmine.SpyObj; + let workContextServiceSpy: jasmine.SpyObj; + + const createMockTask = (overrides: Partial = {}): Task => + ({ + id: 'task-1', + title: 'Test Task', + projectId: 'project-1', + tagIds: [], + subTaskIds: [], + timeSpentOnDay: {}, + timeSpent: 0, + timeEstimate: 0, + isDone: false, + created: Date.now(), + attachments: [], + ...overrides, + }) as Task; + + const createMockIssueProvider = ( + overrides: Partial = {}, + ): IssueProvider => + ({ + id: 'provider-1', + issueProviderKey: ICAL_TYPE, + isEnabled: true, + isAutoPoll: true, + isAutoAddToBacklog: false, + isIntegratedAddTaskBar: false, + defaultProjectId: null, + pinnedSearch: null, + ...overrides, + }) as IssueProvider; + + beforeEach(() => { + issueServiceSpy = jasmine.createSpyObj('IssueService', [ + 'getPollInterval', + 'refreshIssueTasks', + ]); + workContextServiceSpy = jasmine.createSpyObj('WorkContextService', [], { + allTasksForCurrentContext$: of([]), + }); + + // Default: calendar poll interval is 10 minutes + issueServiceSpy.getPollInterval.and.callFake((providerKey: string) => { + if (providerKey === ICAL_TYPE) return 600000; // 10 minutes + if (providerKey === GITHUB_TYPE) return 300000; // 5 minutes + return 0; + }); + + TestBed.configureTestingModule({ + providers: [ + PollIssueUpdatesEffects, + provideMockActions(() => actions$), + provideMockStore({ + selectors: [ + { selector: selectEnabledIssueProviders, value: [] }, + { selector: selectAllCalendarIssueTasks, value: [] }, + ], + }), + { provide: IssueService, useValue: issueServiceSpy }, + { provide: WorkContextService, useValue: workContextServiceSpy }, + ], + }); + + effects = TestBed.inject(PollIssueUpdatesEffects); + store = TestBed.inject(MockStore); + }); + + afterEach(() => { + store.resetSelectors(); + }); + + describe('pollIssueChangesForCurrentContext$', () => { + it('should be created', () => { + expect(effects).toBeTruthy(); + }); + + it('should trigger polling when setActiveWorkContext action is dispatched', fakeAsync(() => { + const calendarProvider = createMockIssueProvider({ + id: 'cal-provider-1', + issueProviderKey: ICAL_TYPE, + }); + + const calendarTask = createMockTask({ + id: 'cal-task-1', + issueId: 'cal-event-123', + issueType: ICAL_TYPE, + issueProviderId: 'cal-provider-1', + }); + + store.overrideSelector(selectEnabledIssueProviders, [calendarProvider]); + store.overrideSelector(selectAllCalendarIssueTasks, [calendarTask]); + store.refreshState(); + + const actionsSubject = new Subject(); + actions$ = actionsSubject.asObservable(); + + // Subscribe to the effect + effects.pollIssueChangesForCurrentContext$.subscribe(); + + // Dispatch the action + actionsSubject.next( + setActiveWorkContext({ + activeType: WorkContextType.PROJECT, + activeId: 'project-1', + }), + ); + + // Wait for the delay before polling starts (default is 10 seconds) + tick(10001); + + // Should call refreshIssueTasks with calendar tasks + expect(issueServiceSpy.refreshIssueTasks).toHaveBeenCalledWith( + [calendarTask], + calendarProvider, + ); + })); + + it('should use selectAllCalendarIssueTasks for ICAL providers instead of current context', fakeAsync(() => { + // Setup: Calendar provider and tasks in DIFFERENT projects + const calendarProvider = createMockIssueProvider({ + id: 'cal-provider-1', + issueProviderKey: ICAL_TYPE, + }); + + // Calendar tasks from multiple projects + const calTaskProject1 = createMockTask({ + id: 'cal-task-1', + projectId: 'project-1', + issueId: 'cal-event-1', + issueType: ICAL_TYPE, + issueProviderId: 'cal-provider-1', + }); + + const calTaskProject2 = createMockTask({ + id: 'cal-task-2', + projectId: 'project-2', // Different project + issueId: 'cal-event-2', + issueType: ICAL_TYPE, + issueProviderId: 'cal-provider-1', + }); + + const calTaskProject3 = createMockTask({ + id: 'cal-task-3', + projectId: 'project-3', // Third project + issueId: 'cal-event-3', + issueType: ICAL_TYPE, + issueProviderId: 'cal-provider-1', + }); + + // Current context only has tasks from project-1 + const currentContextTasks: TaskWithSubTasks[] = [ + { ...calTaskProject1, subTasks: [] }, + ]; + + // But selectAllCalendarIssueTasks returns tasks from ALL projects + const allCalendarTasks = [calTaskProject1, calTaskProject2, calTaskProject3]; + + store.overrideSelector(selectEnabledIssueProviders, [calendarProvider]); + store.overrideSelector(selectAllCalendarIssueTasks, allCalendarTasks); + store.refreshState(); + + // Mock current context to only return project-1 tasks + Object.defineProperty(workContextServiceSpy, 'allTasksForCurrentContext$', { + get: () => of(currentContextTasks), + }); + + const actionsSubject = new Subject(); + actions$ = actionsSubject.asObservable(); + + effects.pollIssueChangesForCurrentContext$.subscribe(); + + actionsSubject.next( + setActiveWorkContext({ + activeType: WorkContextType.PROJECT, + activeId: 'project-1', + }), + ); + + tick(10001); + + // Should call refreshIssueTasks with ALL calendar tasks, not just current context + expect(issueServiceSpy.refreshIssueTasks).toHaveBeenCalledWith( + allCalendarTasks, + calendarProvider, + ); + + // Verify it's NOT using current context tasks (which would only have 1 task) + const callArgs = issueServiceSpy.refreshIssueTasks.calls.mostRecent().args; + expect(callArgs[0].length).toBe(3); // All 3 calendar tasks + })); + + it('should use current context tasks for non-ICAL providers like GITHUB', fakeAsync(() => { + const githubProvider = createMockIssueProvider({ + id: 'github-provider-1', + issueProviderKey: GITHUB_TYPE, + }); + + const githubTaskCurrentContext = createMockTask({ + id: 'github-task-1', + projectId: 'project-1', + issueId: 'issue-123', + issueType: GITHUB_TYPE, + issueProviderId: 'github-provider-1', + }); + + const currentContextTasks: TaskWithSubTasks[] = [ + { ...githubTaskCurrentContext, subTasks: [] }, + ]; + + store.overrideSelector(selectEnabledIssueProviders, [githubProvider]); + store.overrideSelector(selectAllCalendarIssueTasks, []); // No calendar tasks + store.refreshState(); + + Object.defineProperty(workContextServiceSpy, 'allTasksForCurrentContext$', { + get: () => of(currentContextTasks), + }); + + const actionsSubject = new Subject(); + actions$ = actionsSubject.asObservable(); + + effects.pollIssueChangesForCurrentContext$.subscribe(); + + actionsSubject.next( + setActiveWorkContext({ + activeType: WorkContextType.PROJECT, + activeId: 'project-1', + }), + ); + + tick(10001); + + // For GITHUB provider, should use current context tasks + expect(issueServiceSpy.refreshIssueTasks).toHaveBeenCalled(); + const callArgs = issueServiceSpy.refreshIssueTasks.calls.mostRecent().args; + // Verify the task has the expected properties (subTasks may be added by stream) + expect(callArgs[0][0].id).toBe('github-task-1'); + expect(callArgs[0][0].issueType).toBe(GITHUB_TYPE); + expect(callArgs[0][0].issueProviderId).toBe('github-provider-1'); + expect(callArgs[1]).toEqual(githubProvider); + })); + + it('should filter calendar tasks by provider ID', fakeAsync(() => { + const calendarProvider1 = createMockIssueProvider({ + id: 'cal-provider-1', + issueProviderKey: ICAL_TYPE, + }); + + const calendarProvider2 = createMockIssueProvider({ + id: 'cal-provider-2', + issueProviderKey: ICAL_TYPE, + }); + + const calTaskProvider1 = createMockTask({ + id: 'cal-task-1', + issueId: 'cal-event-1', + issueType: ICAL_TYPE, + issueProviderId: 'cal-provider-1', + }); + + const calTaskProvider2 = createMockTask({ + id: 'cal-task-2', + issueId: 'cal-event-2', + issueType: ICAL_TYPE, + issueProviderId: 'cal-provider-2', + }); + + store.overrideSelector(selectEnabledIssueProviders, [ + calendarProvider1, + calendarProvider2, + ]); + store.overrideSelector(selectAllCalendarIssueTasks, [ + calTaskProvider1, + calTaskProvider2, + ]); + store.refreshState(); + + const actionsSubject = new Subject(); + actions$ = actionsSubject.asObservable(); + + effects.pollIssueChangesForCurrentContext$.subscribe(); + + actionsSubject.next( + setActiveWorkContext({ + activeType: WorkContextType.PROJECT, + activeId: 'project-1', + }), + ); + + tick(10001); + + // Should be called twice - once for each provider + expect(issueServiceSpy.refreshIssueTasks).toHaveBeenCalledTimes(2); + + // First call should only have tasks for provider 1 + const firstCall = issueServiceSpy.refreshIssueTasks.calls.argsFor(0); + expect(firstCall[0]).toEqual([calTaskProvider1]); + expect(firstCall[1]).toEqual(calendarProvider1); + + // Second call should only have tasks for provider 2 + const secondCall = issueServiceSpy.refreshIssueTasks.calls.argsFor(1); + expect(secondCall[0]).toEqual([calTaskProvider2]); + expect(secondCall[1]).toEqual(calendarProvider2); + })); + + it('should not poll providers with isAutoPoll set to false', fakeAsync(() => { + const calendarProvider = createMockIssueProvider({ + id: 'cal-provider-1', + issueProviderKey: ICAL_TYPE, + isAutoPoll: false, // Disabled + }); + + store.overrideSelector(selectEnabledIssueProviders, [calendarProvider]); + store.refreshState(); + + const actionsSubject = new Subject(); + actions$ = actionsSubject.asObservable(); + + effects.pollIssueChangesForCurrentContext$.subscribe(); + + actionsSubject.next( + setActiveWorkContext({ + activeType: WorkContextType.PROJECT, + activeId: 'project-1', + }), + ); + + tick(10001); + + // Should NOT call refreshIssueTasks since auto-poll is disabled + expect(issueServiceSpy.refreshIssueTasks).not.toHaveBeenCalled(); + })); + + it('should not poll providers with 0 poll interval', fakeAsync(() => { + const calendarProvider = createMockIssueProvider({ + id: 'cal-provider-1', + issueProviderKey: JIRA_TYPE, // JIRA returns 0 poll interval + }); + + // Override to return 0 for JIRA + issueServiceSpy.getPollInterval.and.callFake((providerKey: string) => { + if (providerKey === JIRA_TYPE) return 0; + return 600000; + }); + + store.overrideSelector(selectEnabledIssueProviders, [calendarProvider]); + store.refreshState(); + + const actionsSubject = new Subject(); + actions$ = actionsSubject.asObservable(); + + effects.pollIssueChangesForCurrentContext$.subscribe(); + + actionsSubject.next( + setActiveWorkContext({ + activeType: WorkContextType.PROJECT, + activeId: 'project-1', + }), + ); + + tick(10001); + + // Should NOT call refreshIssueTasks since poll interval is 0 + expect(issueServiceSpy.refreshIssueTasks).not.toHaveBeenCalled(); + })); + + it('should handle empty providers array gracefully', fakeAsync(() => { + store.overrideSelector(selectEnabledIssueProviders, []); + store.refreshState(); + + const actionsSubject = new Subject(); + actions$ = actionsSubject.asObservable(); + + effects.pollIssueChangesForCurrentContext$.subscribe(); + + actionsSubject.next( + setActiveWorkContext({ + activeType: WorkContextType.PROJECT, + activeId: 'project-1', + }), + ); + + tick(10001); + + // Should NOT call refreshIssueTasks since no providers + expect(issueServiceSpy.refreshIssueTasks).not.toHaveBeenCalled(); + })); + + it('should not call refreshIssueTasks when no tasks match the provider', fakeAsync(() => { + const calendarProvider = createMockIssueProvider({ + id: 'cal-provider-1', + issueProviderKey: ICAL_TYPE, + }); + + // Return empty array - no tasks match this provider + store.overrideSelector(selectEnabledIssueProviders, [calendarProvider]); + store.overrideSelector(selectAllCalendarIssueTasks, []); + store.refreshState(); + + const actionsSubject = new Subject(); + actions$ = actionsSubject.asObservable(); + + effects.pollIssueChangesForCurrentContext$.subscribe(); + + actionsSubject.next( + setActiveWorkContext({ + activeType: WorkContextType.PROJECT, + activeId: 'project-1', + }), + ); + + tick(10001); + + // Should NOT call refreshIssueTasks since no tasks match + expect(issueServiceSpy.refreshIssueTasks).not.toHaveBeenCalled(); + })); + + it('should filter out tasks without issueId', fakeAsync(() => { + const calendarProvider = createMockIssueProvider({ + id: 'cal-provider-1', + issueProviderKey: ICAL_TYPE, + }); + + const validTask = createMockTask({ + id: 'cal-task-1', + issueId: 'cal-event-123', + issueType: ICAL_TYPE, + issueProviderId: 'cal-provider-1', + }); + + // Task without issueId (corrupted data) + const invalidTask = createMockTask({ + id: 'cal-task-2', + issueId: undefined as any, // Missing issueId + issueType: ICAL_TYPE, + issueProviderId: 'cal-provider-1', + }); + + store.overrideSelector(selectEnabledIssueProviders, [calendarProvider]); + store.overrideSelector(selectAllCalendarIssueTasks, [validTask, invalidTask]); + store.refreshState(); + + const actionsSubject = new Subject(); + actions$ = actionsSubject.asObservable(); + + effects.pollIssueChangesForCurrentContext$.subscribe(); + + actionsSubject.next( + setActiveWorkContext({ + activeType: WorkContextType.PROJECT, + activeId: 'project-1', + }), + ); + + tick(10001); + + // Should only call with valid task (the one with issueId) + expect(issueServiceSpy.refreshIssueTasks).toHaveBeenCalledWith( + [validTask], + calendarProvider, + ); + })); + + it('should continue polling after an error occurs', fakeAsync(() => { + const calendarProvider = createMockIssueProvider({ + id: 'cal-provider-1', + issueProviderKey: ICAL_TYPE, + }); + + const calendarTask = createMockTask({ + id: 'cal-task-1', + issueId: 'cal-event-123', + issueType: ICAL_TYPE, + issueProviderId: 'cal-provider-1', + }); + + store.overrideSelector(selectEnabledIssueProviders, [calendarProvider]); + store.overrideSelector(selectAllCalendarIssueTasks, [calendarTask]); + store.refreshState(); + + // First call throws error, second succeeds + let callCount = 0; + issueServiceSpy.refreshIssueTasks.and.callFake(() => { + callCount++; + if (callCount === 1) { + throw new Error('Network error'); + } + return Promise.resolve(); + }); + + const actionsSubject = new Subject(); + actions$ = actionsSubject.asObservable(); + + effects.pollIssueChangesForCurrentContext$.subscribe(); + + actionsSubject.next( + setActiveWorkContext({ + activeType: WorkContextType.PROJECT, + activeId: 'project-1', + }), + ); + + // First poll (should fail but not crash) + tick(10001); + expect(issueServiceSpy.refreshIssueTasks).toHaveBeenCalledTimes(1); + + // Second poll (should succeed) + tick(600000); // 10 minutes + expect(issueServiceSpy.refreshIssueTasks).toHaveBeenCalledTimes(2); + })); + + it('should trigger polling on loadAllData action', fakeAsync(() => { + const calendarProvider = createMockIssueProvider({ + id: 'cal-provider-1', + issueProviderKey: ICAL_TYPE, + }); + + const calendarTask = createMockTask({ + id: 'cal-task-1', + issueId: 'cal-event-123', + issueType: ICAL_TYPE, + issueProviderId: 'cal-provider-1', + }); + + store.overrideSelector(selectEnabledIssueProviders, [calendarProvider]); + store.overrideSelector(selectAllCalendarIssueTasks, [calendarTask]); + store.refreshState(); + + const actionsSubject = new Subject(); + actions$ = actionsSubject.asObservable(); + + effects.pollIssueChangesForCurrentContext$.subscribe(); + + // Use loadAllData instead of setActiveWorkContext + actionsSubject.next(loadAllData({ appDataComplete: {} as any })); + + tick(10001); + + expect(issueServiceSpy.refreshIssueTasks).toHaveBeenCalledWith( + [calendarTask], + calendarProvider, + ); + })); + }); +}); diff --git a/src/app/features/issue/store/poll-issue-updates.effects.ts b/src/app/features/issue/store/poll-issue-updates.effects.ts index 633b423b9..b225d938d 100644 --- a/src/app/features/issue/store/poll-issue-updates.effects.ts +++ b/src/app/features/issue/store/poll-issue-updates.effects.ts @@ -1,16 +1,18 @@ import { Injectable, inject } from '@angular/core'; import { Actions, createEffect, ofType } from '@ngrx/effects'; -import { forkJoin, Observable, timer } from 'rxjs'; +import { EMPTY, merge, Observable, timer } from 'rxjs'; import { first, map, switchMap, tap } from 'rxjs/operators'; import { IssueService } from '../issue.service'; -import { TaskWithSubTasks } from '../../tasks/task.model'; +import { Task, TaskWithSubTasks } from '../../tasks/task.model'; import { WorkContextService } from '../../work-context/work-context.service'; import { setActiveWorkContext } from '../../work-context/store/work-context.actions'; import { loadAllData } from '../../../root-store/meta/load-all-data.action'; import { Store } from '@ngrx/store'; import { IssueProvider } from '../issue.model'; import { selectEnabledIssueProviders } from './issue-provider.selectors'; -import { DELAY_BEFORE_ISSUE_POLLING } from '../issue.const'; +import { DELAY_BEFORE_ISSUE_POLLING, ICAL_TYPE } from '../issue.const'; +import { selectAllCalendarIssueTasks } from '../../tasks/store/task.selectors'; +import { IssueLog } from '../../../core/log'; @Injectable() export class PollIssueUpdatesEffects { @@ -27,44 +29,89 @@ export class PollIssueUpdatesEffects { this.pollIssueTaskUpdatesActions$.pipe( switchMap(() => this._store.select(selectEnabledIssueProviders).pipe(first())), // Get the list of enabled issue providers - switchMap((enabledProviders: IssueProvider[]) => - forkJoin( - // For each enabled provider, start a polling timer - enabledProviders - // only for providers that have auto-polling enabled - .filter((provider) => provider.isAutoPoll) - // filter out providers with 0 poll interval (no polling) - .filter( - (provider) => - this._issueService.getPollInterval(provider.issueProviderKey) > 0, - ) - .map((provider) => - timer( - DELAY_BEFORE_ISSUE_POLLING, - this._issueService.getPollInterval(provider.issueProviderKey), - ).pipe( - // => whenever the provider specific poll timer ticks: - // --------------------------------------------------- - // Get all tasks for the current context - switchMap(() => - this._workContextService.allTasksForCurrentContext$.pipe( - // get once each cycle and no updates - first(), - map((tasks) => - // only use tasks that are assigned to the current issue provider - tasks.filter((task) => task.issueProviderId === provider.id), - ), - ), - ), - // Refresh issue tasks for the current provider - tap((issueTasks: TaskWithSubTasks[]) => - this._issueService.refreshIssueTasks(issueTasks, provider), - ), - ), + switchMap((enabledProviders: IssueProvider[]) => { + const providers = enabledProviders + // only for providers that have auto-polling enabled + .filter((provider) => provider.isAutoPoll) + // filter out providers with 0 poll interval (no polling) + .filter( + (provider) => + this._issueService.getPollInterval(provider.issueProviderKey) > 0, + ); + + // Handle empty providers case + if (providers.length === 0) { + return EMPTY; + } + + // Use merge instead of forkJoin so each timer can emit independently + // (forkJoin waits for all observables to complete, but timer never completes) + return merge( + ...providers.map((provider) => + timer( + DELAY_BEFORE_ISSUE_POLLING, + this._issueService.getPollInterval(provider.issueProviderKey), + ).pipe( + // => whenever the provider specific poll timer ticks: + // --------------------------------------------------- + // Get tasks to refresh based on provider type + switchMap(() => this._getTasksForProvider(provider)), + // Refresh issue tasks for the current provider + // Use try-catch to prevent errors from killing the polling stream + tap((issueTasks: Task[]) => { + if (issueTasks.length > 0) { + try { + this._issueService.refreshIssueTasks(issueTasks, provider); + } catch (err) { + IssueLog.error( + 'Error polling issue updates for ' + provider.id, + err, + ); + } + } + }), ), - ), - ), + ), + ); + }), ), { dispatch: false }, ); + + /** + * Gets tasks to refresh for a provider. + * For calendar (ICAL) providers, returns ALL calendar tasks across all projects + * since calendar events can be assigned to any project. + * For other providers, returns only tasks in the current work context. + */ + private _getTasksForProvider(provider: IssueProvider): Observable { + if (provider.issueProviderKey === ICAL_TYPE) { + // For calendar providers, poll ALL calendar tasks across all projects + // This ensures calendar event updates are synced regardless of which project is active + return this._store.select(selectAllCalendarIssueTasks).pipe( + first(), + map((tasks) => + tasks.filter( + (task) => + task.issueProviderId === provider.id && + // Safety: ensure task has valid issueId to prevent errors in refreshIssueTasks + !!task.issueId, + ), + ), + ); + } + + // For other providers, only poll tasks in the current context + return this._workContextService.allTasksForCurrentContext$.pipe( + first(), + map((tasks: TaskWithSubTasks[]) => + tasks.filter( + (task) => + task.issueProviderId === provider.id && + // Safety: ensure task has valid issueId + !!task.issueId, + ), + ), + ); + } } diff --git a/src/app/features/tasks/store/task.selectors.spec.ts b/src/app/features/tasks/store/task.selectors.spec.ts index c6574fcc5..de4a4e1c4 100644 --- a/src/app/features/tasks/store/task.selectors.spec.ts +++ b/src/app/features/tasks/store/task.selectors.spec.ts @@ -388,6 +388,88 @@ describe('Task Selectors', () => { expect(result[0]).toBe('ISSUE-123'); }); + it('should select all calendar issue tasks', () => { + const result = fromSelectors.selectAllCalendarIssueTasks(mockState); + expect(result.length).toBe(1); + expect(result[0].id).toBe('task8'); + expect(result[0].issueType).toBe('ICAL'); + }); + + it('should select all calendar issue tasks from multiple projects', () => { + // Create a state with multiple calendar tasks across different projects + const multiCalendarTasks: { [id: string]: Task } = { + ...mockTasks, + calTask1: { + id: 'calTask1', + title: 'Calendar Task 1', + created: Date.now(), + isDone: false, + subTaskIds: [], + tagIds: [], + projectId: 'project1', + timeSpentOnDay: {}, + issueId: 'CAL-001', + issueType: 'ICAL', + issueProviderId: 'cal-provider-1', + timeEstimate: 3600000, + timeSpent: 0, + attachments: [], + }, + calTask2: { + id: 'calTask2', + title: 'Calendar Task 2', + created: Date.now(), + isDone: false, + subTaskIds: [], + tagIds: [], + projectId: 'project2', // Different project + timeSpentOnDay: {}, + issueId: 'CAL-002', + issueType: 'ICAL', + issueProviderId: 'cal-provider-1', + timeEstimate: 1800000, + timeSpent: 0, + attachments: [], + }, + calTask3: { + id: 'calTask3', + title: 'Calendar Task 3', + created: Date.now(), + isDone: false, + subTaskIds: [], + tagIds: [], + projectId: 'project3', // Third project (hidden) + timeSpentOnDay: {}, + issueId: 'CAL-003', + issueType: 'ICAL', + issueProviderId: 'cal-provider-2', // Different provider + timeEstimate: 7200000, + timeSpent: 0, + attachments: [], + }, + }; + + const multiCalendarState = { + ...mockState, + [TASK_FEATURE_NAME]: { + ...mockTaskState, + ids: Object.keys(multiCalendarTasks), + entities: multiCalendarTasks, + }, + }; + + const result = fromSelectors.selectAllCalendarIssueTasks(multiCalendarState); + // Should return all 4 ICAL tasks (task8 + 3 new ones) + expect(result.length).toBe(4); + // All should have issueType ICAL + expect(result.every((t) => t.issueType === 'ICAL')).toBe(true); + // Should include tasks from all projects + const projectIds = result.map((t) => t.projectId); + expect(projectIds).toContain('project1'); + expect(projectIds).toContain('project2'); + expect(projectIds).toContain('project3'); + }); + it('should select tasks worked on or done for a day', () => { const result = fromSelectors.selectTasksWorkedOnOrDoneFlat(mockState, { day: today, diff --git a/src/app/features/tasks/store/task.selectors.ts b/src/app/features/tasks/store/task.selectors.ts index c49bbf8df..c3874782c 100644 --- a/src/app/features/tasks/store/task.selectors.ts +++ b/src/app/features/tasks/store/task.selectors.ts @@ -415,6 +415,11 @@ export const selectAllCalendarTaskEventIds = createSelector( tasks.filter((task) => task.issueType === 'ICAL').map((t) => t.issueId as string), ); +export const selectAllCalendarIssueTasks = createSelector( + selectAllTasks, + (tasks: Task[]): Task[] => tasks.filter((task) => task.issueType === 'ICAL'), +); + export const selectTasksWorkedOnOrDoneFlat = createSelector( selectAllTasks, (tasks: Task[], props: { day: string }) => { diff --git a/src/app/t.const.ts b/src/app/t.const.ts index 588bcf55c..7eea6003e 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -1458,6 +1458,7 @@ const T = { REMINDER_ADDED: 'F.TASK.S.REMINDER_ADDED', REMINDER_DELETED: 'F.TASK.S.REMINDER_DELETED', REMINDER_UPDATED: 'F.TASK.S.REMINDER_UPDATED', + TASK_ALREADY_EXISTS: 'F.TASK.S.TASK_ALREADY_EXISTS', TASK_CREATED: 'F.TASK.S.TASK_CREATED', }, SELECT_OR_CREATE: 'F.TASK.SELECT_OR_CREATE', diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index b705ac3df..66c65513c 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1487,6 +1487,7 @@ "REMINDER_ADDED": "Scheduled {{title}} at {{date}}", "REMINDER_DELETED": "Deleted reminder for task", "REMINDER_UPDATED": "Updated reminder for task \"{{title}}\"", + "TASK_ALREADY_EXISTS": "Task {{title}} already exists", "TASK_CREATED": "Created task \"{{taskTitle}}\"" }, "SELECT_OR_CREATE": "Select or create task",