Skip to content

Commit bfa0fa7

Browse files
committed
feat: v2.4.0 — fix CPU spike (#26), DST recurrence (#27), add diagnostics export
- Cap RRULE expansion at 500 occurrences to prevent runaway CPU usage - Date-fallback RECURRENCE-ID matching for DST boundary crossings - 30-second fetch timeout with AbortController - Concurrent update guard (isUpdating) for calendar-service and calendar-manager - Per-event error isolation in recurrence expander - Export Diagnostics button in settings UI - Enhanced logger: 500-entry buffer, getFormattedLogs, getErrorLogs, clearLogs - New diagnostics-service with redacted URL reporting - eventTimezone extraction and propagation through parser and expander - 572 tests (27 new) covering all v2.4.0 changes Closes #26, Closes #27
1 parent 55432d5 commit bfa0fa7

23 files changed

+1671
-103
lines changed

README.md

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -159,13 +159,12 @@ Each button can use a different calendar from your library - just select it from
159159

160160
---
161161

162-
## What's New in v2.3
162+
## What's New in v2.4
163163

164-
- **Named Calendars** - Create a library of calendars with friendly names
165-
- **Per-Button Selection** - Each button picks its calendar from a dropdown
166-
- **Configurable Thresholds** - Customize orange/red warning times
167-
- **Graceful Deletion** - Buttons auto-migrate when calendar is removed
168-
- **Improved UI** - Cleaner settings with collapsible help
164+
- **Stability Fixes** - Capped RRULE expansion, fetch timeouts, concurrent update guards (#26)
165+
- **DST-Aware Recurrence** - Fixed RECURRENCE-ID matching across daylight saving transitions (#27)
166+
- **Export Diagnostics** - One-click diagnostic report for easier bug reporting
167+
- **Error Isolation** - Bad events no longer break the entire calendar
169168

170169
See [full changelog](#changelog) for all versions.
171170

@@ -179,7 +178,7 @@ See [full changelog](#changelog) for all versions.
179178
|------------|---------|
180179
| TypeScript | Type-safe plugin code |
181180
| Node.js SDK v2 | Stream Deck integration |
182-
| Vitest | 529 unit tests |
181+
| Vitest | 572 unit tests |
183182
| Rollup | Bundle optimization |
184183
| rrule | Recurring event expansion |
185184
| Luxon | Timezone handling |
@@ -196,6 +195,15 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for:
196195

197196
## Changelog
198197

198+
### v2.4.0 (2026)
199+
- 🐛 Fixed CPU spike from unbounded RRULE expansion (#26)
200+
- 🐛 Fixed RECURRENCE-ID mismatch across DST boundaries (#27)
201+
- ✨ Export Diagnostics button for one-click bug reports
202+
- ✨ 30-second fetch timeout to prevent hangs
203+
- ✨ Concurrent update guard prevents stacking requests
204+
- ✨ Per-event error isolation for resilient parsing
205+
- ✨ Enhanced debug logging (500-entry buffer, error filtering)
206+
199207
### v2.3.0 (2026)
200208
- ✨ Named Calendars with friendly names
201209
- ✨ Per-button calendar selection
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
BEGIN:VCALENDAR
2+
VERSION:2.0
3+
PRODID:-//Google Inc//Google Calendar 70.9054//EN
4+
X-WR-CALNAME:Stress Test Calendar
5+
X-WR-TIMEZONE:America/New_York
6+
BEGIN:VEVENT
7+
UID:minutely-event@stresstest
8+
DTSTART:20260101T090000Z
9+
DTEND:20260101T090500Z
10+
RRULE:FREQ=MINUTELY;INTERVAL=5
11+
SUMMARY:Every 5 Minutes (no end)
12+
DESCRIPTION:Minutely recurring event with no COUNT or UNTIL - stress test
13+
STATUS:CONFIRMED
14+
END:VEVENT
15+
BEGIN:VEVENT
16+
UID:daily-noend@stresstest
17+
DTSTART:20200101T080000Z
18+
DTEND:20200101T083000Z
19+
RRULE:FREQ=DAILY
20+
SUMMARY:Daily Since 2020 (no end)
21+
DESCRIPTION:Daily recurring since 2020 - many potential occurrences
22+
STATUS:CONFIRMED
23+
END:VEVENT
24+
BEGIN:VEVENT
25+
UID:hourly-event@stresstest
26+
DTSTART:20260101T000000Z
27+
DTEND:20260101T003000Z
28+
RRULE:FREQ=HOURLY;COUNT=5000
29+
SUMMARY:Hourly With High Count
30+
DESCRIPTION:Hourly event with COUNT=5000 - stress test
31+
STATUS:CONFIRMED
32+
END:VEVENT
33+
BEGIN:VEVENT
34+
UID:normal-weekly@stresstest
35+
DTSTART:20260105T100000Z
36+
DTEND:20260105T110000Z
37+
RRULE:FREQ=WEEKLY;BYDAY=MO
38+
SUMMARY:Normal Weekly Meeting
39+
DESCRIPTION:A normal weekly event that should still work
40+
STATUS:CONFIRMED
41+
END:VEVENT
42+
END:VCALENDAR
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
BEGIN:VCALENDAR
2+
VERSION:2.0
3+
PRODID:-//Microsoft Corporation//Outlook 16.0 MIMEDIR//EN
4+
METHOD:PUBLISH
5+
X-WR-TIMEZONE:FLE Standard Time
6+
BEGIN:VTIMEZONE
7+
TZID:FLE Standard Time
8+
BEGIN:STANDARD
9+
DTSTART:16011028T040000
10+
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
11+
TZOFFSETFROM:+0300
12+
TZOFFSETTO:+0200
13+
END:STANDARD
14+
BEGIN:DAYLIGHT
15+
DTSTART:16010325T030000
16+
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
17+
TZOFFSETFROM:+0200
18+
TZOFFSETTO:+0300
19+
END:DAYLIGHT
20+
END:VTIMEZONE
21+
BEGIN:VEVENT
22+
UID:recurrence-dst-test@outlook
23+
DTSTART;TZID=FLE Standard Time:20250701T150000
24+
DTEND;TZID=FLE Standard Time:20250701T160000
25+
RRULE:FREQ=WEEKLY;BYDAY=TU;COUNT=30
26+
SUMMARY:Weekly DST Crossing Meeting
27+
DESCRIPTION:Recurring meeting that crosses a DST boundary
28+
STATUS:CONFIRMED
29+
END:VEVENT
30+
BEGIN:VEVENT
31+
UID:recurrence-dst-test@outlook
32+
RECURRENCE-ID;TZID=FLE Standard Time:20251104T150000
33+
DTSTART;TZID=FLE Standard Time:20251104T160000
34+
DTEND;TZID=FLE Standard Time:20251104T170000
35+
SUMMARY:Weekly DST Crossing Meeting (Moved)
36+
DESCRIPTION:This occurrence was moved 1 hour later
37+
STATUS:CONFIRMED
38+
END:VEVENT
39+
END:VCALENDAR
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
BEGIN:VCALENDAR
2+
VERSION:2.0
3+
PRODID:-//Microsoft Corporation//Outlook 16.0 MIMEDIR//EN
4+
METHOD:PUBLISH
5+
X-WR-TIMEZONE:FLE Standard Time
6+
BEGIN:VTIMEZONE
7+
TZID:FLE Standard Time
8+
BEGIN:STANDARD
9+
DTSTART:16011028T040000
10+
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
11+
TZOFFSETFROM:+0300
12+
TZOFFSETTO:+0200
13+
END:STANDARD
14+
BEGIN:DAYLIGHT
15+
DTSTART:16010325T030000
16+
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
17+
TZOFFSETFROM:+0200
18+
TZOFFSETTO:+0300
19+
END:DAYLIGHT
20+
END:VTIMEZONE
21+
BEGIN:VEVENT
22+
UID:simple-recurrence-id@outlook
23+
DTSTART;TZID=FLE Standard Time:20260211T150000
24+
DTEND;TZID=FLE Standard Time:20260211T160000
25+
RRULE:FREQ=WEEKLY;BYDAY=WE;COUNT=10
26+
SUMMARY:Weekly Team Sync
27+
DESCRIPTION:Regular weekly sync meeting
28+
STATUS:CONFIRMED
29+
END:VEVENT
30+
BEGIN:VEVENT
31+
UID:simple-recurrence-id@outlook
32+
RECURRENCE-ID;TZID=FLE Standard Time:20260218T150000
33+
DTSTART;TZID=FLE Standard Time:20260218T163000
34+
DTEND;TZID=FLE Standard Time:20260218T173000
35+
SUMMARY:Weekly Team Sync (Rescheduled)
36+
DESCRIPTION:Moved to later time
37+
STATUS:CONFIRMED
38+
END:VEVENT
39+
END:VCALENDAR

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.3.0.0",
60+
"Version": "2.4.0.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.3.0",
3+
"version": "2.4.0",
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",

pi/setup.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,10 @@
313313
<button id="refresh" style="background: #3a3a3a; color: #fff; border: none; border-radius: 4px; padding: 10px 24px; font-size: 12px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 6px;">🔄 Force Refresh All Calendars</button>
314314
</div>
315315

316+
<div style="text-align: center; padding: 0 0 10px 0;">
317+
<button id="export-diagnostics" style="background: #2a2a2a; color: #999; border: 1px solid #3a3a3a; border-radius: 4px; padding: 8px 18px; font-size: 11px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 6px;" title="Copy diagnostic info to clipboard for bug reports">📋 Export Diagnostics</button>
318+
</div>
319+
316320
<hr class="sdpi-separator" style="margin: 15px 0; border-color: #3a3a3a;" />
317321

318322
<!-- Collapsible Help Section -->

pi/setup.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,7 @@ document.addEventListener('DOMContentLoaded', function() {
466466
document.getElementById('save')?.addEventListener('click', saveUrl);
467467
document.getElementById('refresh')?.addEventListener('click', refreshIcal);
468468
document.getElementById('refresh-debug')?.addEventListener('click', requestDebugInfo);
469+
document.getElementById('export-diagnostics')?.addEventListener('click', requestDiagnostics);
469470

470471
// Auto-save on settings changes
471472
const settingsInputs = ['timeWindow', 'excludeAllDay', 'titleDisplayDuration', 'flashOnMeetingStart', 'orangeThreshold', 'redThreshold'];
@@ -489,6 +490,45 @@ document.addEventListener('DOMContentLoaded', function() {
489490
requestDebugInfo();
490491
});
491492

493+
/**
494+
* Request diagnostic report from plugin and copy to clipboard
495+
*/
496+
function requestDiagnostics() {
497+
const opener = getOpener();
498+
console.log('[SETUP] requestDiagnostics called');
499+
500+
const btn = document.getElementById('export-diagnostics');
501+
if (btn) {
502+
btn.textContent = '⏳ Generating...';
503+
btn.disabled = true;
504+
}
505+
506+
if (opener && opener.websocket) {
507+
const json = {
508+
event: 'sendToPlugin',
509+
action: opener.actionInfo?.action || 'com.pedrofuentes.ical.combined',
510+
context: opener.uuid,
511+
payload: { action: 'getDiagnostics' }
512+
};
513+
opener.websocket.send(JSON.stringify(json));
514+
515+
// Timeout fallback in case the plugin doesn't respond
516+
setTimeout(() => {
517+
if (btn && btn.disabled) {
518+
btn.textContent = '📋 Export Diagnostics';
519+
btn.disabled = false;
520+
showAlert('No response from plugin. Is the plugin running?');
521+
}
522+
}, 5000);
523+
} else {
524+
if (btn) {
525+
btn.textContent = '📋 Export Diagnostics';
526+
btn.disabled = false;
527+
}
528+
showAlert('Error: Cannot communicate with plugin.');
529+
}
530+
}
531+
492532
/**
493533
* Request debug info from plugin
494534
*/
@@ -578,6 +618,41 @@ function handlePluginMessage(message) {
578618
}
579619
}
580620
}
621+
622+
if (message && message.action === 'diagnosticReport') {
623+
const btn = document.getElementById('export-diagnostics');
624+
const text = message.data && message.data.text;
625+
if (text) {
626+
// Copy to clipboard
627+
navigator.clipboard.writeText(text).then(() => {
628+
if (btn) {
629+
btn.textContent = '✅ Copied!';
630+
btn.disabled = false;
631+
setTimeout(() => { btn.textContent = '📋 Export Diagnostics'; }, 3000);
632+
}
633+
showAlert('Diagnostics copied to clipboard! Paste into a GitHub issue.', 'notice', 5);
634+
}).catch(() => {
635+
// Fallback: open in a new window
636+
const w = window.open('', '_blank', 'width=700,height=500');
637+
if (w) {
638+
w.document.write('<pre style="white-space:pre-wrap;font-size:12px;">' +
639+
text.replace(/&/g,'&amp;').replace(/</g,'&lt;') + '</pre>');
640+
w.document.title = 'Diagnostic Report';
641+
}
642+
if (btn) {
643+
btn.textContent = '📋 Export Diagnostics';
644+
btn.disabled = false;
645+
}
646+
showAlert('Clipboard not available. Report opened in a new window.', 'notice', 5);
647+
});
648+
} else {
649+
if (btn) {
650+
btn.textContent = '📋 Export Diagnostics';
651+
btn.disabled = false;
652+
}
653+
showAlert('No diagnostic data received.');
654+
}
655+
}
581656
}
582657

583658
// Listen for messages from opener window

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.3.0.0",
57+
"Version": "2.4.0.0",
5858
"Nodejs": {
5959
"Version": "20",
6060
"Debug": "enabled"

src/plugin.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { startPeriodicUpdates, stopPeriodicUpdates, calendarCache, getDebugInfo,
1515
import { calendarManager } from './services/calendar-manager.js';
1616
import { setGlobalCalendarConfig, setNamedCalendars, migrateDeletedCalendars } from './actions/base-action.js';
1717
import { logger, isDebugMode } from './utils/logger.js';
18+
import { compileDiagnosticReport, formatDiagnosticText } from './services/diagnostics-service.js';
1819
import { NamedCalendar } from './types/index.js';
1920

2021
// Global settings
@@ -227,6 +228,20 @@ streamDeck.ui.onSendToPlugin((ev) => {
227228
logger.warn('Cannot send debug info: streamDeck.ui.current is undefined');
228229
}
229230
}
231+
232+
if (payload && payload.action === 'getDiagnostics') {
233+
logger.info('Diagnostics export requested from PI');
234+
if (streamDeck.ui.current) {
235+
const report = compileDiagnosticReport();
236+
const text = formatDiagnosticText(report);
237+
streamDeck.ui.current.sendToPropertyInspector({
238+
action: 'diagnosticReport',
239+
data: { text }
240+
} as any);
241+
} else {
242+
logger.warn('Cannot send diagnostics: streamDeck.ui.current is undefined');
243+
}
244+
}
230245
});
231246

232247
/**

0 commit comments

Comments
 (0)