mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
fix(calendar): add periodic refresh for planner and scheduler views
The icalEvents$ observable now refreshes periodically using a timer-based approach. This ensures calendar events update in planner and scheduler views without requiring an app restart. Key changes: - Add LOCAL_FILE_CHECK_INTERVAL (5 min) for file:// URLs - Add getEffectiveCheckInterval() to determine poll interval per provider - Refactor icalEvents$ to use timer(0, minInterval) for periodic refresh - Move shareReplay to outer observable to prevent memory leaks - Add comprehensive test coverage (84 tests) Fixes #4474
This commit is contained in:
parent
386c636e5f
commit
77c4c33988
5 changed files with 1686 additions and 87 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -18,6 +18,7 @@ import {
|
|||
merge,
|
||||
Observable,
|
||||
of,
|
||||
timer,
|
||||
} from 'rxjs';
|
||||
import { T } from '../../t.const';
|
||||
import { SnackService } from '../../core/snack/snack.service';
|
||||
|
|
@ -43,6 +44,7 @@ import {
|
|||
getCalendarEventIdCandidates,
|
||||
matchesAnyCalendarEventId,
|
||||
} from './get-calendar-event-id-candidates';
|
||||
import { getEffectiveCheckInterval } from '../issue/providers/calendar/calendar.const';
|
||||
|
||||
const ONE_MONTHS = 60 * 60 * 1000 * 24 * 31;
|
||||
|
||||
|
|
@ -60,77 +62,96 @@ export class CalendarIntegrationService {
|
|||
this._store.select(selectCalendarProviders).pipe(
|
||||
distinctUntilChanged(fastArrayCompare),
|
||||
switchMap((calendarProviders) => {
|
||||
return calendarProviders && calendarProviders.length
|
||||
? forkJoin(
|
||||
calendarProviders.map((calProvider) => {
|
||||
if (!calProvider.isEnabled) {
|
||||
return of({
|
||||
itemsForProvider: [] as CalendarIntegrationEvent[],
|
||||
calProvider,
|
||||
didError: false,
|
||||
});
|
||||
}
|
||||
|
||||
return this.requestEventsForSchedule$(calProvider, true).pipe(
|
||||
first(),
|
||||
map((itemsForProvider: CalendarIntegrationEvent[]) => ({
|
||||
itemsForProvider,
|
||||
calProvider,
|
||||
didError: false,
|
||||
})),
|
||||
catchError(() =>
|
||||
of({
|
||||
itemsForProvider: [] as CalendarIntegrationEvent[],
|
||||
calProvider,
|
||||
didError: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}),
|
||||
).pipe(
|
||||
switchMap((resultForProviders) =>
|
||||
combineLatest([
|
||||
this._store
|
||||
.select(selectAllCalendarTaskEventIds)
|
||||
.pipe(distinctUntilChanged(fastArrayCompare)),
|
||||
this.skippedEventIds$.pipe(distinctUntilChanged(fastArrayCompare)),
|
||||
]).pipe(
|
||||
// tap((val) => Log.log('selectAllCalendarTaskEventIds', val)),
|
||||
map(([allCalendarTaskEventIds, skippedEventIds]) => {
|
||||
const cachedByProviderId = this._groupCachedEventsByProvider(
|
||||
this._getCalProviderFromCache(),
|
||||
);
|
||||
return resultForProviders.map(
|
||||
({ itemsForProvider, calProvider, didError }) => {
|
||||
// Fall back to cached data when the live fetch errored so offline mode keeps showing events.
|
||||
const sourceItems: ScheduleFromCalendarEvent[] = didError
|
||||
? (cachedByProviderId.get(calProvider.id) ?? [])
|
||||
: (itemsForProvider as ScheduleFromCalendarEvent[]);
|
||||
return {
|
||||
// filter out items already added as tasks
|
||||
items: sourceItems.filter(
|
||||
(calEv) =>
|
||||
!matchesAnyCalendarEventId(
|
||||
calEv,
|
||||
allCalendarTaskEventIds,
|
||||
) && !matchesAnyCalendarEventId(calEv, skippedEventIds),
|
||||
),
|
||||
} as ScheduleCalendarMapEntry;
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
// tap((v) => Log.log('icalEvents$ final', v)),
|
||||
tap((val) => {
|
||||
saveToRealLs(LS.CAL_EVENTS_CACHE, val);
|
||||
}),
|
||||
)
|
||||
: (of([]) as Observable<ScheduleCalendarMapEntry[]>);
|
||||
if (!calendarProviders?.length) {
|
||||
return of([]) as Observable<ScheduleCalendarMapEntry[]>;
|
||||
}
|
||||
// Calculate the minimum refresh interval from all enabled providers
|
||||
const minInterval = this._getMinRefreshInterval(calendarProviders);
|
||||
// Use timer to periodically refresh calendar data
|
||||
return timer(0, minInterval).pipe(
|
||||
switchMap(() => this._fetchAllProviders(calendarProviders)),
|
||||
);
|
||||
}),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
),
|
||||
);
|
||||
).pipe(shareReplay({ bufferSize: 1, refCount: true }));
|
||||
|
||||
private _fetchAllProviders(
|
||||
calendarProviders: IssueProviderCalendar[],
|
||||
): Observable<ScheduleCalendarMapEntry[]> {
|
||||
return forkJoin(
|
||||
calendarProviders.map((calProvider) => {
|
||||
if (!calProvider.isEnabled) {
|
||||
return of({
|
||||
itemsForProvider: [] as CalendarIntegrationEvent[],
|
||||
calProvider,
|
||||
didError: false,
|
||||
});
|
||||
}
|
||||
|
||||
return this.requestEventsForSchedule$(calProvider, true).pipe(
|
||||
first(),
|
||||
map((itemsForProvider: CalendarIntegrationEvent[]) => ({
|
||||
itemsForProvider,
|
||||
calProvider,
|
||||
didError: false,
|
||||
})),
|
||||
catchError(() =>
|
||||
of({
|
||||
itemsForProvider: [] as CalendarIntegrationEvent[],
|
||||
calProvider,
|
||||
didError: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}),
|
||||
).pipe(
|
||||
switchMap((resultForProviders) =>
|
||||
combineLatest([
|
||||
this._store
|
||||
.select(selectAllCalendarTaskEventIds)
|
||||
.pipe(distinctUntilChanged(fastArrayCompare)),
|
||||
this.skippedEventIds$.pipe(distinctUntilChanged(fastArrayCompare)),
|
||||
]).pipe(
|
||||
map(([allCalendarTaskEventIds, skippedEventIds]) => {
|
||||
const cachedByProviderId = this._groupCachedEventsByProvider(
|
||||
this._getCalProviderFromCache(),
|
||||
);
|
||||
return resultForProviders.map(
|
||||
({ itemsForProvider, calProvider, didError }) => {
|
||||
// Fall back to cached data when the live fetch errored so offline mode keeps showing events.
|
||||
const sourceItems: ScheduleFromCalendarEvent[] = didError
|
||||
? (cachedByProviderId.get(calProvider.id) ?? [])
|
||||
: (itemsForProvider as ScheduleFromCalendarEvent[]);
|
||||
return {
|
||||
// filter out items already added as tasks
|
||||
items: sourceItems.filter(
|
||||
(calEv) =>
|
||||
!matchesAnyCalendarEventId(calEv, allCalendarTaskEventIds) &&
|
||||
!matchesAnyCalendarEventId(calEv, skippedEventIds),
|
||||
),
|
||||
} as ScheduleCalendarMapEntry;
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
tap((val) => {
|
||||
saveToRealLs(LS.CAL_EVENTS_CACHE, val);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the minimum refresh interval from all enabled providers.
|
||||
* Uses getEffectiveCheckInterval which returns 5 min for file:// URLs.
|
||||
*/
|
||||
private _getMinRefreshInterval(calendarProviders: IssueProviderCalendar[]): number {
|
||||
const enabledProviders = calendarProviders.filter((p) => p.isEnabled && p.icalUrl);
|
||||
if (!enabledProviders.length) {
|
||||
return 2 * 60 * 60 * 1000; // Default 2 hours
|
||||
}
|
||||
return Math.min(...enabledProviders.map((p) => getEffectiveCheckInterval(p)));
|
||||
}
|
||||
|
||||
public readonly skippedEventIds$ = new BehaviorSubject<string[]>([]);
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
matchesAnyCalendarEventId,
|
||||
shareCalendarEventId,
|
||||
} from '../get-calendar-event-id-candidates';
|
||||
import { getEffectiveCheckInterval } from '../../issue/providers/calendar/calendar.const';
|
||||
|
||||
const CHECK_TO_SHOW_INTERVAL = 60 * 1000;
|
||||
|
||||
|
|
@ -62,8 +63,7 @@ export class CalendarIntegrationEffects {
|
|||
|
||||
return forkJoin(
|
||||
activatedProviders.map((calProvider) =>
|
||||
timer(0, calProvider.checkUpdatesEvery).pipe(
|
||||
// timer(0, 10000).pipe(
|
||||
timer(0, getEffectiveCheckInterval(calProvider)).pipe(
|
||||
// tap(() => Log.log('REQUEST CALENDAR', calProvider)),
|
||||
switchMap(() =>
|
||||
this._calendarIntegrationService.requestEvents$(calProvider),
|
||||
|
|
|
|||
109
src/app/features/issue/providers/calendar/calendar.const.spec.ts
Normal file
109
src/app/features/issue/providers/calendar/calendar.const.spec.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import {
|
||||
getEffectiveCheckInterval,
|
||||
LOCAL_FILE_CHECK_INTERVAL,
|
||||
DEFAULT_CALENDAR_CFG,
|
||||
} from './calendar.const';
|
||||
import { IssueProviderCalendar } from '../../issue.model';
|
||||
|
||||
describe('calendar.const', () => {
|
||||
describe('LOCAL_FILE_CHECK_INTERVAL', () => {
|
||||
it('should be 5 minutes in milliseconds', () => {
|
||||
expect(LOCAL_FILE_CHECK_INTERVAL).toBe(5 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEffectiveCheckInterval', () => {
|
||||
const createMockProvider = (
|
||||
overrides: Partial<IssueProviderCalendar> = {},
|
||||
): IssueProviderCalendar =>
|
||||
({
|
||||
id: 'test-provider',
|
||||
isEnabled: true,
|
||||
issueProviderKey: 'ICAL',
|
||||
icalUrl: 'https://example.com/calendar.ics',
|
||||
checkUpdatesEvery: DEFAULT_CALENDAR_CFG.checkUpdatesEvery,
|
||||
showBannerBeforeThreshold: DEFAULT_CALENDAR_CFG.showBannerBeforeThreshold,
|
||||
isAutoImportForCurrentDay: false,
|
||||
isDisabledForWebApp: false,
|
||||
...overrides,
|
||||
}) as IssueProviderCalendar;
|
||||
|
||||
it('should return LOCAL_FILE_CHECK_INTERVAL for file:// URLs', () => {
|
||||
const provider = createMockProvider({
|
||||
icalUrl: 'file:///home/user/calendar.ics',
|
||||
});
|
||||
|
||||
expect(getEffectiveCheckInterval(provider)).toBe(LOCAL_FILE_CHECK_INTERVAL);
|
||||
});
|
||||
|
||||
it('should return LOCAL_FILE_CHECK_INTERVAL for file:// URLs with different paths', () => {
|
||||
const provider = createMockProvider({
|
||||
icalUrl: 'file:///C:/Users/test/calendar.ics',
|
||||
});
|
||||
|
||||
expect(getEffectiveCheckInterval(provider)).toBe(LOCAL_FILE_CHECK_INTERVAL);
|
||||
});
|
||||
|
||||
it('should return checkUpdatesEvery for http:// URLs', () => {
|
||||
const customInterval = 30 * 60 * 1000; // 30 minutes
|
||||
const provider = createMockProvider({
|
||||
icalUrl: 'http://example.com/calendar.ics',
|
||||
checkUpdatesEvery: customInterval,
|
||||
});
|
||||
|
||||
expect(getEffectiveCheckInterval(provider)).toBe(customInterval);
|
||||
});
|
||||
|
||||
it('should return checkUpdatesEvery for https:// URLs', () => {
|
||||
const customInterval = 60 * 60 * 1000; // 1 hour
|
||||
const provider = createMockProvider({
|
||||
icalUrl: 'https://calendar.google.com/calendar.ics',
|
||||
checkUpdatesEvery: customInterval,
|
||||
});
|
||||
|
||||
expect(getEffectiveCheckInterval(provider)).toBe(customInterval);
|
||||
});
|
||||
|
||||
it('should return default checkUpdatesEvery when no custom interval set', () => {
|
||||
const provider = createMockProvider({
|
||||
icalUrl: 'https://example.com/calendar.ics',
|
||||
});
|
||||
|
||||
expect(getEffectiveCheckInterval(provider)).toBe(
|
||||
DEFAULT_CALENDAR_CFG.checkUpdatesEvery,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle undefined icalUrl gracefully', () => {
|
||||
const provider = createMockProvider({
|
||||
icalUrl: undefined as unknown as string,
|
||||
});
|
||||
|
||||
expect(getEffectiveCheckInterval(provider)).toBe(
|
||||
DEFAULT_CALENDAR_CFG.checkUpdatesEvery,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty string icalUrl', () => {
|
||||
const provider = createMockProvider({
|
||||
icalUrl: '',
|
||||
});
|
||||
|
||||
expect(getEffectiveCheckInterval(provider)).toBe(
|
||||
DEFAULT_CALENDAR_CFG.checkUpdatesEvery,
|
||||
);
|
||||
});
|
||||
|
||||
it('should be case-sensitive for file:// protocol', () => {
|
||||
// file:// should be lowercase per URI spec
|
||||
const provider = createMockProvider({
|
||||
icalUrl: 'FILE:///home/user/calendar.ics',
|
||||
});
|
||||
|
||||
// FILE:// doesn't match file://, so should use default interval
|
||||
expect(getEffectiveCheckInterval(provider)).toBe(
|
||||
DEFAULT_CALENDAR_CFG.checkUpdatesEvery,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -6,6 +6,16 @@ import { ISSUE_PROVIDER_FF_DEFAULT_PROJECT } from '../../common-issue-form-stuff
|
|||
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;
|
||||
|
||||
export const getEffectiveCheckInterval = (calProvider: IssueProviderCalendar): number => {
|
||||
if (calProvider.icalUrl?.startsWith('file://')) {
|
||||
return LOCAL_FILE_CHECK_INTERVAL;
|
||||
}
|
||||
return calProvider.checkUpdatesEvery;
|
||||
};
|
||||
|
||||
export const DEFAULT_CALENDAR_CFG: CalendarProviderCfg = {
|
||||
isEnabled: false,
|
||||
icalUrl: '',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue