mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-22 18:30:09 +00:00
feat(calendar): implement polling for calendar task updates and enhance data retrieval logic
#4474
This commit is contained in:
parent
159b28948f
commit
5ee3fb2e23
3 changed files with 329 additions and 23 deletions
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue