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
This commit is contained in:
Johannes Millan 2026-01-05 18:26:57 +01:00
parent 08971fce47
commit c2b7627125
8 changed files with 1063 additions and 64 deletions

View file

@ -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<TaskService>;
let snackServiceSpy: jasmine.SpyObj<SnackService>;
let workContextServiceSpy: jasmine.SpyObj<WorkContextService>;
let issueProviderServiceSpy: jasmine.SpyObj<IssueProviderService>;
let projectServiceSpy: jasmine.SpyObj<ProjectService>;
let calendarIntegrationServiceSpy: jasmine.SpyObj<CalendarIntegrationService>;
let storeSpy: jasmine.SpyObj<Store>;
let translateServiceSpy: jasmine.SpyObj<TranslateService>;
let globalProgressBarServiceSpy: jasmine.SpyObj<GlobalProgressBarService>;
let navigateToTaskServiceSpy: jasmine.SpyObj<NavigateToTaskService>;
const createMockTask = (overrides: Partial<Task> = {}): 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> = {},
): 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();
});
});
});

View file

@ -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)

View file

@ -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<any>;
// 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<any>;
let store: MockStore;
let issueServiceSpy: jasmine.SpyObj<IssueService>;
let workContextServiceSpy: jasmine.SpyObj<WorkContextService>;
const createMockTask = (overrides: Partial<Task> = {}): 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> = {},
): 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<any>();
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<any>();
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<any>();
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<any>();
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<any>();
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<any>();
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<any>();
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<any>();
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<any>();
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<any>();
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<any>();
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,
);
}));
});
});

View file

@ -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<Task[]> {
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,
),
),
);
}
}

View file

@ -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,

View file

@ -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 }) => {

View file

@ -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',

View file

@ -1487,6 +1487,7 @@
"REMINDER_ADDED": "Scheduled <strong>{{title}}</strong> at <strong>{{date}}</strong>",
"REMINDER_DELETED": "Deleted reminder for task",
"REMINDER_UPDATED": "Updated reminder for task \"{{title}}\"",
"TASK_ALREADY_EXISTS": "Task <strong>{{title}}</strong> already exists",
"TASK_CREATED": "Created task \"{{taskTitle}}\""
},
"SELECT_OR_CREATE": "Select or create task",