From 5ee3fb2e2325ea5aa8a6184ebad9760aa18c8c67 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Mon, 5 Jan 2026 13:20:44 +0100 Subject: [PATCH] feat(calendar): implement polling for calendar task updates and enhance data retrieval logic #4474 --- ...calendar-common-interfaces.service.spec.ts | 266 +++++++++++++++++- .../calendar-common-interfaces.service.ts | 65 ++++- .../providers/calendar/calendar.const.ts | 21 +- 3 files changed, 329 insertions(+), 23 deletions(-) diff --git a/src/app/features/issue/providers/calendar/calendar-common-interfaces.service.spec.ts b/src/app/features/issue/providers/calendar/calendar-common-interfaces.service.spec.ts index be4fb98c5..92a6e4ed0 100644 --- a/src/app/features/issue/providers/calendar/calendar-common-interfaces.service.spec.ts +++ b/src/app/features/issue/providers/calendar/calendar-common-interfaces.service.spec.ts @@ -5,22 +5,34 @@ import { IssueProviderService } from '../../issue-provider.service'; import { HttpClientTestingModule } from '@angular/common/http/testing'; import { ICalIssueReduced } from './calendar.model'; import { getDbDateStr } from '../../../../util/get-db-date-str'; +import { of } from 'rxjs'; +import { Task } from '../../../tasks/task.model'; +import { CalendarIntegrationEvent } from '../../../calendar-integration/calendar-integration.model'; describe('CalendarCommonInterfacesService', () => { let service: CalendarCommonInterfacesService; + let calendarIntegrationServiceSpy: jasmine.SpyObj; + let issueProviderServiceSpy: jasmine.SpyObj; beforeEach(() => { + calendarIntegrationServiceSpy = jasmine.createSpyObj('CalendarIntegrationService', [ + 'requestEventsForSchedule$', + ]); + issueProviderServiceSpy = jasmine.createSpyObj('IssueProviderService', [ + 'getCfgOnce$', + ]); + TestBed.configureTestingModule({ imports: [HttpClientTestingModule], providers: [ CalendarCommonInterfacesService, { provide: CalendarIntegrationService, - useValue: {}, + useValue: calendarIntegrationServiceSpy, }, { provide: IssueProviderService, - useValue: {}, + useValue: issueProviderServiceSpy, }, ], }); @@ -118,4 +130,254 @@ describe('CalendarCommonInterfacesService', () => { expect(result.notes).toBe(''); }); }); + + describe('getFreshDataForIssueTask', () => { + const mockCalendarCfg = { + id: 'provider-1', + isEnabled: true, + icalUrl: 'https://example.com/calendar.ics', + }; + + const createMockTask = (overrides: Partial = {}): Task => + ({ + id: 'task-1', + issueId: 'event-123', + issueProviderId: 'provider-1', + issueType: 'ICAL', + title: 'Original Title', + dueWithTime: new Date('2025-01-15T10:00:00Z').getTime(), + timeEstimate: 3600000, + ...overrides, + }) as Task; + + const createMockCalendarEvent = ( + overrides: Partial = {}, + ): CalendarIntegrationEvent => ({ + id: 'event-123', + calProviderId: 'provider-1', + title: 'Original Title', + start: new Date('2025-01-15T10:00:00Z').getTime(), + duration: 3600000, + ...overrides, + }); + + it('should return null when task has no issueProviderId', async () => { + const task = createMockTask({ issueProviderId: undefined }); + + const result = await service.getFreshDataForIssueTask(task); + + expect(result).toBeNull(); + }); + + it('should return null when task has no issueId', async () => { + const task = createMockTask({ issueId: undefined }); + + const result = await service.getFreshDataForIssueTask(task); + + expect(result).toBeNull(); + }); + + it('should return null when provider config is not found', async () => { + const task = createMockTask(); + issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(null as any)); + + const result = await service.getFreshDataForIssueTask(task); + + expect(result).toBeNull(); + }); + + it('should return null when matching event is not found', async () => { + const task = createMockTask(); + issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(mockCalendarCfg as any)); + calendarIntegrationServiceSpy.requestEventsForSchedule$.and.returnValue(of([])); + + const result = await service.getFreshDataForIssueTask(task); + + expect(result).toBeNull(); + }); + + it('should return null when event has no changes', async () => { + const task = createMockTask(); + const calendarEvent = createMockCalendarEvent(); + issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(mockCalendarCfg as any)); + calendarIntegrationServiceSpy.requestEventsForSchedule$.and.returnValue( + of([calendarEvent]), + ); + + const result = await service.getFreshDataForIssueTask(task); + + expect(result).toBeNull(); + }); + + it('should return taskChanges when event time changed', async () => { + const task = createMockTask(); + const newStartTime = new Date('2025-01-15T14:00:00Z').getTime(); + const calendarEvent = createMockCalendarEvent({ start: newStartTime }); + issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(mockCalendarCfg as any)); + calendarIntegrationServiceSpy.requestEventsForSchedule$.and.returnValue( + of([calendarEvent]), + ); + + const result = await service.getFreshDataForIssueTask(task); + + expect(result).not.toBeNull(); + expect(result!.taskChanges.dueWithTime).toBe(newStartTime); + expect(result!.taskChanges.issueWasUpdated).toBe(true); + expect(result!.issueTitle).toBe('Original Title'); + }); + + it('should return taskChanges when event title changed', async () => { + const task = createMockTask(); + const calendarEvent = createMockCalendarEvent({ title: 'Updated Title' }); + issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(mockCalendarCfg as any)); + calendarIntegrationServiceSpy.requestEventsForSchedule$.and.returnValue( + of([calendarEvent]), + ); + + const result = await service.getFreshDataForIssueTask(task); + + expect(result).not.toBeNull(); + expect(result!.taskChanges.title).toBe('Updated Title'); + expect(result!.issueTitle).toBe('Updated Title'); + }); + + it('should return taskChanges when event duration changed', async () => { + const task = createMockTask(); + const newDuration = 7200000; // 2 hours + const calendarEvent = createMockCalendarEvent({ duration: newDuration }); + issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(mockCalendarCfg as any)); + calendarIntegrationServiceSpy.requestEventsForSchedule$.and.returnValue( + of([calendarEvent]), + ); + + const result = await service.getFreshDataForIssueTask(task); + + expect(result).not.toBeNull(); + expect(result!.taskChanges.timeEstimate).toBe(newDuration); + }); + + it('should match event by legacy ID', async () => { + const task = createMockTask({ issueId: 'legacy-event-id' }); + const calendarEvent = createMockCalendarEvent({ + id: 'new-event-id', + legacyIds: ['legacy-event-id'], + title: 'Updated Title', + }); + issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(mockCalendarCfg as any)); + calendarIntegrationServiceSpy.requestEventsForSchedule$.and.returnValue( + of([calendarEvent]), + ); + + const result = await service.getFreshDataForIssueTask(task); + + expect(result).not.toBeNull(); + expect(result!.taskChanges.title).toBe('Updated Title'); + }); + + it('should handle all-day event conversion correctly', async () => { + const task = createMockTask({ dueWithTime: undefined, dueDay: '2025-01-15' }); + const calendarEvent = createMockCalendarEvent({ + isAllDay: true, + start: new Date('2025-01-16T00:00:00Z').getTime(), + }); + issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(mockCalendarCfg as any)); + calendarIntegrationServiceSpy.requestEventsForSchedule$.and.returnValue( + of([calendarEvent]), + ); + + const result = await service.getFreshDataForIssueTask(task); + + expect(result).not.toBeNull(); + expect(result!.taskChanges.dueDay).toBe( + getDbDateStr(new Date('2025-01-16T00:00:00Z').getTime()), + ); + expect(result!.taskChanges.dueWithTime).toBeUndefined(); + }); + }); + + describe('getFreshDataForIssueTasks', () => { + const mockCalendarCfg = { + id: 'provider-1', + isEnabled: true, + icalUrl: 'https://example.com/calendar.ics', + }; + + it('should return empty array when no tasks have changes', async () => { + const task = { + id: 'task-1', + issueId: 'event-123', + issueProviderId: 'provider-1', + issueType: 'ICAL', + title: 'Same Title', + dueWithTime: new Date('2025-01-15T10:00:00Z').getTime(), + timeEstimate: 3600000, + } as Task; + + const calendarEvent: CalendarIntegrationEvent = { + id: 'event-123', + calProviderId: 'provider-1', + title: 'Same Title', + start: new Date('2025-01-15T10:00:00Z').getTime(), + duration: 3600000, + }; + + issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(mockCalendarCfg as any)); + calendarIntegrationServiceSpy.requestEventsForSchedule$.and.returnValue( + of([calendarEvent]), + ); + + const result = await service.getFreshDataForIssueTasks([task]); + + expect(result).toEqual([]); + }); + + it('should return only tasks with changes', async () => { + const task1 = { + id: 'task-1', + issueId: 'event-1', + issueProviderId: 'provider-1', + issueType: 'ICAL', + title: 'Same Title', + dueWithTime: new Date('2025-01-15T10:00:00Z').getTime(), + timeEstimate: 3600000, + } as Task; + + const task2 = { + id: 'task-2', + issueId: 'event-2', + issueProviderId: 'provider-1', + issueType: 'ICAL', + title: 'Old Title', + dueWithTime: new Date('2025-01-15T11:00:00Z').getTime(), + timeEstimate: 3600000, + } as Task; + + const calendarEvent1: CalendarIntegrationEvent = { + id: 'event-1', + calProviderId: 'provider-1', + title: 'Same Title', + start: new Date('2025-01-15T10:00:00Z').getTime(), + duration: 3600000, + }; + + const calendarEvent2: CalendarIntegrationEvent = { + id: 'event-2', + calProviderId: 'provider-1', + title: 'New Title', + start: new Date('2025-01-15T11:00:00Z').getTime(), + duration: 3600000, + }; + + issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(mockCalendarCfg as any)); + calendarIntegrationServiceSpy.requestEventsForSchedule$.and.returnValue( + of([calendarEvent1, calendarEvent2]), + ); + + const result = await service.getFreshDataForIssueTasks([task1, task2]); + + expect(result.length).toBe(1); + expect(result[0].task.id).toBe('task-2'); + expect(result[0].taskChanges.title).toBe('New Title'); + }); + }); }); diff --git a/src/app/features/issue/providers/calendar/calendar-common-interfaces.service.ts b/src/app/features/issue/providers/calendar/calendar-common-interfaces.service.ts index cee225779..3632aebe5 100644 --- a/src/app/features/issue/providers/calendar/calendar-common-interfaces.service.ts +++ b/src/app/features/issue/providers/calendar/calendar-common-interfaces.service.ts @@ -9,12 +9,14 @@ import { SearchResultItem, } from '../../issue.model'; import { CalendarIntegrationService } from '../../../calendar-integration/calendar-integration.service'; -import { map, switchMap } from 'rxjs/operators'; +import { first, map, switchMap } from 'rxjs/operators'; +import { matchesAnyCalendarEventId } from '../../../calendar-integration/get-calendar-event-id-candidates'; import { IssueProviderService } from '../../issue-provider.service'; import { CalendarProviderCfg, ICalIssueReduced } from './calendar.model'; import { HttpClient } from '@angular/common/http'; import { ICAL_TYPE } from '../../issue.const'; import { getDbDateStr } from '../../../../util/get-db-date-str'; +import { CALENDAR_POLL_INTERVAL } from './calendar.const'; @Injectable({ providedIn: 'root', @@ -28,8 +30,7 @@ export class CalendarCommonInterfacesService implements IssueServiceInterface { return cfg.isEnabled && cfg.icalUrl?.length > 0; } - // We currently don't support polling for calendar events - pollInterval: number = 0; + pollInterval: number = CALENDAR_POLL_INTERVAL; issueLink(issueId: number, issueProviderId: string): Promise { return Promise.resolve('NONE'); @@ -92,7 +93,13 @@ export class CalendarCommonInterfacesService implements IssueServiceInterface { issue: IssueData; issueTitle: string; } | null> { - return null; + const results = await this.getFreshDataForIssueTasks([task]); + if (!results.length) return null; + return { + taskChanges: results[0].taskChanges, + issue: results[0].issue, + issueTitle: (results[0].issue as unknown as ICalIssueReduced).title, + }; } async getFreshDataForIssueTasks(tasks: Task[]): Promise< @@ -102,7 +109,55 @@ export class CalendarCommonInterfacesService implements IssueServiceInterface { issue: IssueData; }[] > { - return []; + // Group tasks by provider to minimize fetches + const tasksByProvider = new Map(); + for (const task of tasks) { + if (!task.issueProviderId || !task.issueId) continue; + const existing = tasksByProvider.get(task.issueProviderId) || []; + existing.push(task); + tasksByProvider.set(task.issueProviderId, existing); + } + + const results: { + task: Readonly; + taskChanges: Partial>; + issue: IssueData; + }[] = []; + + for (const [providerId, providerTasks] of tasksByProvider) { + const cfg = await this._getCfgOnce$(providerId).pipe(first()).toPromise(); + if (!cfg) continue; + + const events = await this._calendarIntegrationService + .requestEventsForSchedule$(cfg, false) + .pipe(first()) + .toPromise(); + if (!events?.length) continue; + + for (const task of providerTasks) { + const matchingEvent = events.find((ev) => + matchesAnyCalendarEventId(ev, [task.issueId as string]), + ); + if (!matchingEvent) continue; + + const taskData = this.getAddTaskData(matchingEvent); + const hasChanges = + taskData.dueWithTime !== task.dueWithTime || + taskData.dueDay !== task.dueDay || + taskData.title !== task.title || + taskData.timeEstimate !== task.timeEstimate; + + if (hasChanges) { + results.push({ + task, + taskChanges: { ...taskData, issueWasUpdated: true }, + issue: matchingEvent as unknown as IssueData, + }); + } + } + } + + return results; } async getNewIssuesToAddToBacklog( diff --git a/src/app/features/issue/providers/calendar/calendar.const.ts b/src/app/features/issue/providers/calendar/calendar.const.ts index 2f7bdab0f..a6e4c7e27 100644 --- a/src/app/features/issue/providers/calendar/calendar.const.ts +++ b/src/app/features/issue/providers/calendar/calendar.const.ts @@ -2,13 +2,16 @@ import { ConfigFormSection } from '../../../config/global-config.model'; import { T } from '../../../../t.const'; import { IssueProviderCalendar } from '../../issue.model'; import { CalendarProviderCfg } from './calendar.model'; -import { ISSUE_PROVIDER_FF_DEFAULT_PROJECT } from '../../common-issue-form-stuff.const'; +import { ISSUE_PROVIDER_COMMON_FORM_FIELDS } from '../../common-issue-form-stuff.const'; import { IS_ELECTRON } from '../../../../app.constants'; import { IssueLog } from '../../../../core/log'; // 5 minutes for local file:// URLs (faster polling for local calendars) export const LOCAL_FILE_CHECK_INTERVAL = 5 * 60 * 1000; +// Poll interval for checking calendar task updates (10 minutes) +export const CALENDAR_POLL_INTERVAL = 10 * 60 * 1000; + export const getEffectiveCheckInterval = (calProvider: IssueProviderCalendar): number => { if (calProvider.icalUrl?.startsWith('file://')) { return LOCAL_FILE_CHECK_INTERVAL; @@ -51,7 +54,6 @@ export const CALENDAR_FORM_CFG_NEW: ConfigFormSection = { label: T.GCF.CALENDARS.CAL_PATH, }, }, - ISSUE_PROVIDER_FF_DEFAULT_PROJECT, { type: 'duration', key: 'checkUpdatesEvery', @@ -106,19 +108,6 @@ export const CALENDAR_FORM_CFG_NEW: ConfigFormSection = { label: 'Disable when using web application', }, }, - // { - // type: 'icon', - // key: 'icon', - // hooks: { - // onInit: (field) => { - // if (!field?.formControl?.value) { - // field?.formControl?.setValue('event'); - // } - // }, - // }, - // templateOptions: { - // label: T.GCF.CALENDARS.ICON, - // }, - // }, + ...ISSUE_PROVIDER_COMMON_FORM_FIELDS, ], };