Skip to content

Commit e6da25e

Browse files
committed
fix: silent background calendar refresh — no more Loading iCal flicker
- Background refreshes no longer set LOADING status when cache has data - Background refresh failures preserve stale cached events instead of clearing - Initial load errors still display correctly - Added tests for silent refresh behavior Bump version to 2.4.1
1 parent bfa0fa7 commit e6da25e

File tree

8 files changed

+196
-24
lines changed

8 files changed

+196
-24
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,10 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for:
195195

196196
## Changelog
197197

198+
### v2.4.1 (2026)
199+
- 🐛 Fixed buttons showing "Loading iCal" on every background calendar refresh
200+
- 🐛 Background refresh failures now preserve cached data instead of clearing events
201+
198202
### v2.4.0 (2026)
199203
- 🐛 Fixed CPU spike from unbounded RRULE expansion (#26)
200204
- 🐛 Fixed RECURRENCE-ID mismatch across DST boundaries (#27)

manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
"CategoryIcon": "assets/icalIcon",
5858
"URL": "https://github.com/pedrofuentes/stream-deck-ical#readme",
5959
"PropertyInspectorPath": "pi.html",
60-
"Version": "2.4.0.0",
60+
"Version": "2.4.1.0",
6161
"Nodejs": {
6262
"Version": "20",
6363
"Debug": "enabled"

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "stream-deck-ical",
3-
"version": "2.4.0",
3+
"version": "2.4.1",
44
"description": "An elgato Stream Deck plugin that displays information from your calendar using an iCal URL. Visual cues and a countdown will help you end meetings on time and be ready for the next one.",
55
"private": true,
66
"type": "module",

src/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
"CategoryIcon": "assets/icalIcon",
5555
"URL": "https://github.com/pedrofuentes/stream-deck-ical#readme",
5656
"PropertyInspectorPath": "pi.html",
57-
"Version": "2.4.0.0",
57+
"Version": "2.4.1.0",
5858
"Nodejs": {
5959
"Version": "20",
6060
"Debug": "enabled"

src/services/calendar-manager.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -376,9 +376,13 @@ class CalendarManager {
376376
}
377377

378378
calendar.isUpdating = true;
379+
const isBackgroundRefresh = cache.status === 'LOADED' || cache.status === 'NO_EVENTS';
379380
try {
380-
cache.status = 'LOADING';
381-
logger.info(`[CalendarManager] Updating ${calendar.id} (${timeWindow} day window, excludeAllDay=${excludeAllDay})`);
381+
// Only show LOADING on initial fetch — background refreshes stay silent
382+
if (!isBackgroundRefresh) {
383+
cache.status = 'LOADING';
384+
}
385+
logger.info(`[CalendarManager] Updating ${calendar.id} (${timeWindow} day window, excludeAllDay=${excludeAllDay})${isBackgroundRefresh ? ' [background]' : ''}`);
382386

383387
// Fetch the feed
384388
const icsContent = await fetchICalFeed(url);
@@ -431,15 +435,21 @@ class CalendarManager {
431435
const errorMessage = error instanceof Error ? error.message : String(error);
432436
logger.error(`[CalendarManager] ❌ Failed to update ${calendar.id}: ${errorMessage}`);
433437

434-
if (error instanceof TypeError && error.message.includes('fetch')) {
435-
cache.status = 'NETWORK_ERROR';
436-
} else if (error instanceof Error && error.message.includes('parse')) {
437-
cache.status = 'PARSE_ERROR';
438+
if (isBackgroundRefresh) {
439+
// Background refresh failed — keep showing stale cached data
440+
logger.warn(`[CalendarManager] Background refresh failed for ${calendar.id}, keeping cached data`);
438441
} else {
439-
cache.status = 'NETWORK_ERROR';
442+
// Initial load failed — show error state
443+
if (error instanceof TypeError && error.message.includes('fetch')) {
444+
cache.status = 'NETWORK_ERROR';
445+
} else if (error instanceof Error && error.message.includes('parse')) {
446+
cache.status = 'PARSE_ERROR';
447+
} else {
448+
cache.status = 'NETWORK_ERROR';
449+
}
450+
451+
cache.events = [];
440452
}
441-
442-
cache.events = [];
443453
} finally {
444454
calendar.isUpdating = false;
445455
}

src/services/calendar-service.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,13 @@ export async function updateCalendarCache(
115115
}
116116

117117
isUpdating = true;
118+
const isBackgroundRefresh = calendarCache.status === 'LOADED' || calendarCache.status === 'NO_EVENTS';
118119
try {
119-
calendarCache.status = 'LOADING';
120-
logger.info(`🔄 Updating calendar cache (${timeWindowDays} day window, excludeAllDay=${excludeAllDay})...`);
120+
// Only show LOADING on initial fetch — background refreshes stay silent
121+
if (!isBackgroundRefresh) {
122+
calendarCache.status = 'LOADING';
123+
}
124+
logger.info(`🔄 Updating calendar cache (${timeWindowDays} day window, excludeAllDay=${excludeAllDay})${isBackgroundRefresh ? ' [background]' : ''}...`);
121125

122126
// Fetch the feed
123127
const icsContent = await fetchICalFeed(url);
@@ -163,16 +167,21 @@ export async function updateCalendarCache(
163167
const errorMessage = error instanceof Error ? error.message : String(error);
164168
logger.error(`❌ Failed to update calendar cache: ${errorMessage}`);
165169

166-
// Determine error type
167-
if (error instanceof TypeError && error.message.includes('fetch')) {
168-
calendarCache.status = 'NETWORK_ERROR';
169-
} else if (error instanceof Error && error.message.includes('parse')) {
170-
calendarCache.status = 'PARSE_ERROR';
170+
if (isBackgroundRefresh) {
171+
// Background refresh failed — keep showing stale cached data
172+
logger.warn(`Background refresh failed, keeping cached data`);
171173
} else {
172-
calendarCache.status = 'NETWORK_ERROR';
174+
// Initial load failed — show error state
175+
if (error instanceof TypeError && error.message.includes('fetch')) {
176+
calendarCache.status = 'NETWORK_ERROR';
177+
} else if (error instanceof Error && error.message.includes('parse')) {
178+
calendarCache.status = 'PARSE_ERROR';
179+
} else {
180+
calendarCache.status = 'NETWORK_ERROR';
181+
}
182+
183+
calendarCache.events = [];
173184
}
174-
175-
calendarCache.events = [];
176185
} finally {
177186
isUpdating = false;
178187
}

tests/calendar-manager.test.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ describe('CalendarManager', () => {
328328
});
329329

330330
describe('error handling', () => {
331-
it('should handle network errors', async () => {
331+
it('should handle network errors on initial load', async () => {
332332
mockFetch.mockRejectedValueOnce(new TypeError('Failed to fetch'));
333333

334334
const url = 'https://calendar.google.com/test.ics';
@@ -341,7 +341,7 @@ describe('CalendarManager', () => {
341341
expect(status).toBe('NETWORK_ERROR');
342342
});
343343

344-
it('should handle HTTP errors', async () => {
344+
it('should handle HTTP errors on initial load', async () => {
345345
mockFetch.mockResolvedValueOnce({
346346
ok: false,
347347
status: 404,
@@ -370,6 +370,62 @@ describe('CalendarManager', () => {
370370
});
371371
});
372372

373+
describe('silent background refresh', () => {
374+
it('should NOT set LOADING status during background refresh', async () => {
375+
const url = 'https://calendar.google.com/test.ics';
376+
manager.registerAction('action1', url);
377+
378+
// Wait for initial load to complete
379+
await new Promise(resolve => setTimeout(resolve, 100));
380+
381+
const calendar = manager.getCalendarForAction('action1');
382+
expect(calendar).toBeDefined();
383+
expect(calendar!.cache.status).toBe('LOADED');
384+
385+
// Force a background refresh
386+
await manager.refreshCalendarForAction('action1');
387+
388+
// Status should still be LOADED, never went through LOADING
389+
expect(calendar!.cache.status).toBe('LOADED');
390+
});
391+
392+
it('should preserve cached events when background refresh fails', async () => {
393+
const url = 'https://calendar.google.com/test.ics';
394+
manager.registerAction('action1', url);
395+
396+
// Wait for initial load
397+
await new Promise(resolve => setTimeout(resolve, 100));
398+
399+
const calendar = manager.getCalendarForAction('action1');
400+
expect(calendar!.cache.status).toBe('LOADED');
401+
const eventCount = calendar!.cache.events.length;
402+
expect(eventCount).toBeGreaterThan(0);
403+
404+
// Make next fetch fail
405+
mockFetch.mockRejectedValueOnce(new TypeError('fetch failed'));
406+
407+
// Background refresh fails
408+
await manager.refreshCalendarForAction('action1');
409+
410+
// Should keep stale events and LOADED status
411+
expect(calendar!.cache.status).toBe('LOADED');
412+
expect(calendar!.cache.events.length).toBe(eventCount);
413+
});
414+
415+
it('should still show error on initial load failure', async () => {
416+
mockFetch.mockRejectedValueOnce(new TypeError('fetch failed'));
417+
418+
const url = 'https://calendar.google.com/test-initial.ics';
419+
manager.registerAction('action1', url);
420+
421+
// Wait for async update
422+
await new Promise(resolve => setTimeout(resolve, 100));
423+
424+
// Initial load failure should show error
425+
expect(manager.getStatusForAction('action1')).toBe('NETWORK_ERROR');
426+
});
427+
});
428+
373429
describe('clear', () => {
374430
it('should clear all calendars and mappings', () => {
375431
manager.registerAction('action1', 'https://cal1.com/test.ics');

tests/calendar-service.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,99 @@ END:VCALENDAR`;
245245
await updateCalendarCache('https://example.com/cal.ics', 3);
246246
expect(calendarCache.version).toBe(2);
247247
});
248+
249+
describe('silent background refresh', () => {
250+
it('should NOT set LOADING status when cache already has data (background refresh)', async () => {
251+
const icsContent = `BEGIN:VCALENDAR
252+
VERSION:2.0
253+
PRODID:-//Test//Test//EN
254+
BEGIN:VEVENT
255+
UID:test-123
256+
DTSTART:${formatICSDate(new Date(Date.now() + 3600000))}
257+
DTEND:${formatICSDate(new Date(Date.now() + 7200000))}
258+
SUMMARY:Test Event
259+
END:VEVENT
260+
END:VCALENDAR`;
261+
262+
global.fetch = vi.fn().mockResolvedValue({
263+
ok: true,
264+
status: 200,
265+
text: () => Promise.resolve(icsContent)
266+
});
267+
268+
// First load — should go through LOADING
269+
await updateCalendarCache('https://example.com/cal.ics', 3);
270+
expect(calendarCache.status).toBe('LOADED');
271+
expect(calendarCache.events.length).toBeGreaterThanOrEqual(1);
272+
273+
// Track status changes during second (background) refresh
274+
let sawLoading = false;
275+
const originalStatus = Object.getOwnPropertyDescriptor(
276+
calendarCache, 'status'
277+
);
278+
let currentStatus = calendarCache.status;
279+
Object.defineProperty(calendarCache, 'status', {
280+
get() { return currentStatus; },
281+
set(val) {
282+
if (val === 'LOADING') sawLoading = true;
283+
currentStatus = val;
284+
},
285+
configurable: true
286+
});
287+
288+
// Background refresh — should NOT go through LOADING
289+
await updateCalendarCache('https://example.com/cal.ics', 3);
290+
291+
// Restore original property
292+
delete (calendarCache as any).status;
293+
calendarCache.status = currentStatus;
294+
295+
expect(sawLoading).toBe(false);
296+
expect(calendarCache.status).toBe('LOADED');
297+
});
298+
299+
it('should preserve cached events when background refresh fails', async () => {
300+
const icsContent = `BEGIN:VCALENDAR
301+
VERSION:2.0
302+
PRODID:-//Test//Test//EN
303+
BEGIN:VEVENT
304+
UID:test-123
305+
DTSTART:${formatICSDate(new Date(Date.now() + 3600000))}
306+
DTEND:${formatICSDate(new Date(Date.now() + 7200000))}
307+
SUMMARY:Test Event
308+
END:VEVENT
309+
END:VCALENDAR`;
310+
311+
global.fetch = vi.fn().mockResolvedValue({
312+
ok: true,
313+
status: 200,
314+
text: () => Promise.resolve(icsContent)
315+
});
316+
317+
// First load — success
318+
await updateCalendarCache('https://example.com/cal.ics', 3);
319+
expect(calendarCache.status).toBe('LOADED');
320+
const eventCount = calendarCache.events.length;
321+
expect(eventCount).toBeGreaterThanOrEqual(1);
322+
323+
// Background refresh — network error
324+
global.fetch = vi.fn().mockRejectedValue(new TypeError('fetch failed'));
325+
await updateCalendarCache('https://example.com/cal.ics', 3);
326+
327+
// Should keep stale events and LOADED status
328+
expect(calendarCache.status).toBe('LOADED');
329+
expect(calendarCache.events.length).toBe(eventCount);
330+
});
331+
332+
it('should still show errors on initial load failure', async () => {
333+
// Cache starts at INIT — not a background refresh
334+
global.fetch = vi.fn().mockRejectedValue(new TypeError('fetch failed'));
335+
await updateCalendarCache('https://example.com/cal.ics', 3);
336+
337+
expect(calendarCache.status).toBe('NETWORK_ERROR');
338+
expect(calendarCache.events).toEqual([]);
339+
});
340+
});
248341
});
249342

250343
describe('startPeriodicUpdates', () => {

0 commit comments

Comments
 (0)