feat(calendar): implement polling for calendar task updates and enhance data retrieval logic

#4474
This commit is contained in:
Johannes Millan 2026-01-05 13:20:44 +01:00
parent 159b28948f
commit 5ee3fb2e23
3 changed files with 329 additions and 23 deletions

View file

@ -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<CalendarIntegrationService>;
let issueProviderServiceSpy: jasmine.SpyObj<IssueProviderService>;
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> = {}): 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> = {},
): 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');
});
});
});

View file

@ -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<string> {
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<string, Task[]>();
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<Task>;
taskChanges: Partial<Readonly<Task>>;
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(

View file

@ -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<IssueProviderCalendar> = {
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<IssueProviderCalendar> = {
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,
],
};