Skip to content

Commit 37679c8

Browse files
committed
Merge feature/v2.3.0-per-action-settings: Named Calendars, configurable thresholds
Features: - Named Calendars with friendly names in Settings (#5) - Per-button calendar selection via dropdown - Configurable warning thresholds (orange/red) - Graceful calendar deletion migration Closes #5
2 parents 1b5743b + 59fce4b commit 37679c8

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

66 files changed

+8268
-544
lines changed

.github/copilot-instructions.md

Lines changed: 104 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,92 @@ streamDeck.actions.registerAction(new TimeLeftAction());
3636
streamDeck.connect(); // Must come AFTER registration
3737
```
3838

39+
### SingletonAction Pattern (CRITICAL)
40+
41+
**SingletonAction means ONE instance handles ALL buttons of that action type.**
42+
43+
This is critical for state management:
44+
- Instance variables (`this.interval`, `this.actionRef`) would be shared/overwritten
45+
- Solution: Use `buttonStates: Map<string, ButtonState>` to track per-button state
46+
- Each method receives `actionId` to operate on the correct button's state
47+
- Subclasses maintain their own per-button state Maps for action-specific data
48+
49+
```typescript
50+
// WRONG - shared state breaks with multiple buttons
51+
class MyAction extends SingletonAction {
52+
private interval?: NodeJS.Timeout; // Overwrites when 2nd button appears!
53+
}
54+
55+
// CORRECT - per-button state
56+
class MyAction extends SingletonAction {
57+
private buttonStates: Map<string, ButtonState> = new Map();
58+
59+
startTimerForButton(actionId: string, action: any) {
60+
const state = this.buttonStates.get(actionId);
61+
state.interval = setInterval(...);
62+
}
63+
}
64+
```
65+
3966
### Property Inspector Communication
4067

4168
PI uses WebSocket, not direct imports. Settings flow:
42-
1. PI calls `setGlobalSettings` via WebSocket
43-
2. Plugin receives via `onDidReceiveGlobalSettings`
44-
3. Plugin processes and updates cache
69+
1. PI calls `setGlobalSettings` via WebSocket for global settings
70+
2. PI calls `setSettings` via WebSocket for per-action settings
71+
3. Plugin receives via `onDidReceiveGlobalSettings` and `onDidReceiveSettings`
72+
4. Plugin processes and updates cache
73+
74+
### Per-Action Settings (v2.3.0)
75+
76+
Each button can select a calendar via `ActionSettings`:
77+
78+
```typescript
79+
interface ActionSettings {
80+
calendarId?: string; // ID of named calendar to use (new in v2.3)
81+
// Legacy fields (kept for backwards compatibility)
82+
useCustomCalendar?: boolean;
83+
customUrl?: string;
84+
customLabel?: string;
85+
customTimeWindow?: 1 | 3 | 5 | 7;
86+
customExcludeAllDay?: boolean;
87+
}
88+
```
89+
90+
**Named Calendars** in GlobalSettings:
91+
```typescript
92+
interface NamedCalendar {
93+
id: string; // Unique ID (e.g., "cal_abc123")
94+
name: string; // User-friendly name (e.g., "Work")
95+
url: string; // iCal URL
96+
timeWindow?: number; // Optional override (1, 3, 5, or 7 days)
97+
excludeAllDay?: boolean;
98+
}
99+
100+
interface GlobalSettings {
101+
calendars?: NamedCalendar[];
102+
defaultCalendarId?: string;
103+
orangeThreshold?: number; // Seconds for orange warning (default: 300 = 5 min)
104+
redThreshold?: number; // Seconds for red warning (default: 30)
105+
// ... other settings
106+
}
107+
```
108+
109+
**CalendarManager** handles multi-calendar support:
110+
- Calendars are deduplicated by URL (same URL = shared cache)
111+
- Reference counting for automatic cleanup
112+
- Each action registers via `calendarManager.registerAction(actionId, url, ...)`
113+
114+
**BaseAction** provides:
115+
- `buttonStates: Map<string, ButtonState>` - Per-button state storage
116+
- `buttonSettings: Map<string, ActionSettings>` - Per-button settings for migration
117+
- `getEventsForButton(actionId)` - Returns events from the button's registered calendar
118+
- `getCacheStatusForButton(actionId)` - Returns status of the button's calendar
119+
- `startTimerForButton(actionId, action)` - Starts update timer for a specific button
120+
- `stopTimerForButton(actionId)` - Stops timer for a specific button
121+
- `setImage(actionId, action, imageName)` - Sets image for a specific button
122+
- `onDidReceiveSettings()` - Handles settings changes, re-registers calendar
123+
- `migrateButtonsWithDeletedCalendar(validIds)` - Migrates buttons when calendar deleted
124+
- `getRedZone()` / `getOrangeZone()` - Get configurable warning thresholds
45125

46126
## Build System
47127

@@ -104,6 +184,21 @@ Excluded dates are parsed and passed to RRuleSet to skip specific occurrences.
104184

105185
## Testing Patterns
106186

187+
### Testing Requirements (MANDATORY)
188+
189+
**Every code change MUST include corresponding tests:**
190+
191+
1. **New features** - Add unit tests covering happy path and edge cases
192+
2. **Bug fixes** - Add regression tests that would have caught the bug
193+
3. **Refactoring** - Ensure existing tests still pass, add tests for any new behavior
194+
195+
**Before completing any task:**
196+
- Run `npm test` to verify all tests pass
197+
- Check test coverage for changed files
198+
- Add tests if coverage is insufficient
199+
200+
**For agents:** Always verify tests are added/updated before marking a task complete.
201+
107202
### Test Fixtures
108203

109204
Located in `__fixtures__/` organized by provider:
@@ -163,13 +258,16 @@ const flashEnabled = settings.flashOnMeetingStart === true;
163258
| Outlook times wrong | Windows timezone not mapped | Check `timezone-service.ts` mapping |
164259
| Buttons stuck on "Loading" | Startup race condition | `waitForCacheAndStart` uses 500ms polling with `actionRef` fallback |
165260
| Title shows for too long | Duration multiplied twice | `getTitleDisplayDuration()` returns seconds, caller multiplies by 1000 |
261+
| Calendar not loading | Named calendars not set up | Ensure at least one calendar is configured in Settings |
262+
| Button using deleted calendar | Calendar was removed | Buttons auto-migrate to default via `migrateDeletedCalendars()` |
263+
| Settings not persisting | Using wrong settings event | Global = `setGlobalSettings`, Per-action = `setSettings` |
166264

167265
## File Locations
168266

169267
- **Actions**: `src/actions/` - Extend `BaseAction`
170-
- **Services**: `src/services/` - Business logic
171-
- **Types**: `src/types/index.ts` - All interfaces
172-
- **PI HTML**: `pi/setup.html` - Settings popup
268+
- **Services**: `src/services/` - Business logic (`calendar-manager.ts` for multi-calendar)
269+
- **Types**: `src/types/index.ts` - All interfaces (`ActionSettings`, `NamedCalendar`, `CalendarInstance`)
270+
- **PI HTML**: `pi/setup.html` - Settings popup (Named Calendars management)
173271
- **PI JS**: `pi/setup.js` - Settings logic (not TypeScript)
174272
- **Tests**: `tests/` - Vitest test files
175273

README.md

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,19 @@ iCal Plugin for [elgato Stream Deck](https://www.elgato.com/en/gaming/stream-dec
66

77
This plugin is available on the Stream Deck store, you can also download [the last release](https://github.com/pedrofuentes/stream-deck-ical/releases) or build it yourself using the code on this repo.
88

9+
## ✨ New in v2.3
10+
11+
-**Named Calendars**: Set up multiple calendars with friendly names in Settings (#5)
12+
- Create a library of calendars (Work, Personal, Family, etc.)
13+
- Each button can select which calendar to use from a dropdown
14+
- Calendars sharing the same URL share a single cache (efficient!)
15+
-**Per-Button Settings**: Each button can have its own time window and all-day event preferences
16+
-**Configurable Warning Thresholds**: Customize when orange/red warnings appear
17+
- Orange warning: Choose from 1, 2, 3, 5, 10, 15, or 30 minutes (default: 5 min)
18+
- Red warning: Choose from 10, 15, 30, 45, or 60 seconds (default: 30 sec)
19+
-**Graceful Calendar Deletion**: Buttons using a deleted calendar automatically fall back to default
20+
-**Improved Settings UI**: Redesigned Property Inspector with collapsible help sections
21+
922
## ✨ New in v2.2
1023

1124
-**Smart Calendar Action**: New all-in-one action that automatically switches between modes (#10)
@@ -29,6 +42,9 @@ This plugin is available on the Stream Deck store, you can also download [the la
2942
-**Provider Compatibility**: Optimized for Google Calendar, Microsoft Outlook/Office 365, and Apple Calendar
3043

3144
## Features ##
45+
***Named calendars** - Set up multiple calendars with friendly names, assign to buttons via dropdown
46+
***Per-button settings** - Each button can use different calendar, time window, and all-day preferences
47+
***Configurable warning thresholds** - Customize orange/red warning times
3248
***Recurring events** with RRULE expansion and EXDATE handling
3349
***Configurable time window** (1, 3, 5, or 7 days)
3450
***Exclude all-day events** option (enabled by default)
@@ -41,8 +57,8 @@ This plugin is available on the Stream Deck store, you can also download [the la
4157

4258
### Time Left ###
4359
* Shows time left until the meeting ends
44-
* Changes icon color to orange when 5 minutes are left on the meeting
45-
* Changes icon color to red when 30 seconds are left on the meeting and goes up to 5 minutes after the meeting
60+
* Changes icon color to orange when time remaining reaches the orange threshold (default: 5 minutes)
61+
* Changes icon color to red when time remaining reaches the red threshold (default: 30 seconds)
4662
* When the meeting ends the counter will keep going and stay red for 5 minutes, if the user pushes the button it will show the next meeting if one is available
4763
* Supports multiple concurrent meetings, to switch between meeting just push the button
4864
* Shows meeting indicator (e.g., "1/3") when multiple meetings are active
@@ -56,7 +72,7 @@ This plugin is available on the Stream Deck store, you can also download [the la
5672
* Scrolling marquee for meeting titles (tap to show/hide)
5773
* Concurrent meeting support (tap to cycle through active meetings)
5874
* Flash alert when meetings start
59-
* Color-coded countdown (orange at 5 min, red at 30 sec)
75+
* Configurable warning thresholds (orange/red)
6076
* **Perfect for limited Stream Deck space** - one button does it all!
6177

6278
### Next Meeting ###
@@ -66,8 +82,8 @@ This plugin is available on the Stream Deck store, you can also download [the la
6682
* If the button is pushed while the text is showing it will go back to show the time left until the next meeting
6783
* At the end of the title animation, the button will go back to show the time left until the next meeting
6884
* **Optional flash alert** when meetings are about to start (disabled by default)
69-
* Changes icon color to orange when there are 5 minutes left for the next meeting to start
70-
* Changes icon color to red when there are 30 seconds left for the next meeting to start
85+
* Changes icon color to orange when time remaining reaches the orange threshold (default: 5 minutes)
86+
* Changes icon color to red when time remaining reaches the red threshold (default: 30 seconds)
7187

7288
## Calendar Provider Support
7389

@@ -129,15 +145,29 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup instructions.
129145

130146
## Configuration
131147

148+
### Global Settings (Settings Popup)
132149
1. Drag an action to your Stream Deck
133150
2. Click the action to open Property Inspector
134151
3. Click "Settings" button
135-
4. Enter your iCal URL
136-
5. Choose time window (1, 3, 5, or 7 days)
137-
6. Set title display duration (5, 10, 15, or 30 seconds)
138-
7. Optionally enable "Flash when meeting starts" for visual alerts
139-
8. Optionally uncheck "Exclude All-Day Events" to show all-day events
140-
9. Click "Save Settings"
152+
4. **Manage Calendars**: Add named calendars with friendly labels
153+
- Click "Add Calendar" to create a new entry
154+
- Give it a name (e.g., "Work", "Personal")
155+
- Paste your iCal URL
156+
- Set time window and all-day event preferences per calendar
157+
- Click ★ to set as default calendar
158+
5. **Warning Thresholds**: Customize when color warnings appear
159+
- Orange warning: 1-30 minutes (default: 5 min)
160+
- Red warning: 10-60 seconds (default: 30 sec)
161+
6. **Other Options**:
162+
- Title display duration (5, 10, 15, or 30 seconds)
163+
- Flash on meeting start (optional visual alert)
164+
7. Click "Save Settings"
165+
166+
### Per-Button Settings (Property Inspector)
167+
Each button can override the default calendar:
168+
1. Select the button on your Stream Deck
169+
2. In Property Inspector, use the "Calendar" dropdown to select which calendar this button should use
170+
3. The default calendar is indicated with "(Default)" in the dropdown
141171

142172
## Troubleshooting
143173

@@ -185,6 +215,19 @@ We welcome contributions! See [CONTRIBUTING.md](CONTRIBUTING.md) for:
185215

186216
## Changelog
187217

218+
### v2.3.0 (2026)
219+
-**Named Calendars**: Set up multiple calendars with friendly names in Settings (#5)
220+
-**Per-Button Calendar Selection**: Each button selects calendar from dropdown
221+
-**Configurable Warning Thresholds**: Customize orange (1-30 min) and red (10-60 sec) warnings
222+
-**Graceful Calendar Deletion**: Buttons auto-migrate to default when calendar is deleted
223+
-**Improved Settings UI**: Redesigned Property Inspector with collapsible help
224+
- ✨ Per-button time window and all-day event settings
225+
- ✨ URL-level caching: buttons sharing same URL share a single cache
226+
- 🐛 Fixed button state preservation with SingletonAction pattern
227+
- 🐛 Fixed calendar selection not persisting after Stream Deck restart
228+
- ✅ Added CalendarManager service with 31 tests
229+
- ✅ Added 104 new regression tests (529 total)
230+
188231
### v2.2.0 (2026)
189232
-**Smart Calendar Action**: New combined action that auto-switches between Time Left and Next Meeting (#10)
190233
- ✅ Added 22 tests for combined action behavior

manifest.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
],
1414
"SupportedInMultiActions": false,
1515
"Tooltip": "Time left on the current meeting",
16+
"UserTitleEnabled": false,
1617
"UUID": "com.pedrofuentes.ical.timeleft"
1718
},
1819
{
@@ -27,6 +28,7 @@
2728
],
2829
"SupportedInMultiActions": false,
2930
"Tooltip": "Countdown until next meeting starts",
31+
"UserTitleEnabled": false,
3032
"UUID": "com.pedrofuentes.ical.nextmeeting"
3133
},
3234
{
@@ -41,6 +43,7 @@
4143
],
4244
"SupportedInMultiActions": false,
4345
"Tooltip": "Shows Time Left when in meeting, Next Meeting otherwise",
46+
"UserTitleEnabled": false,
4447
"UUID": "com.pedrofuentes.ical.combined"
4548
}
4649
],
@@ -54,7 +57,7 @@
5457
"CategoryIcon": "assets/icalIcon",
5558
"URL": "https://github.com/pedrofuentes/stream-deck-ical#readme",
5659
"PropertyInspectorPath": "pi.html",
57-
"Version": "2.2.0.0",
60+
"Version": "2.3.0.0",
5861
"Nodejs": {
5962
"Version": "20",
6063
"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.2.0",
3+
"version": "2.3.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/pi.html

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,37 @@
77
<meta name="apple-mobile-web-app-status-bar-style" content="black">
88
<title>iCal Property Inspector</title>
99
<link rel="stylesheet" href="css/sdpi.css">
10+
<style>
11+
.no-calendars-warning {
12+
color: #f44336;
13+
font-size: 11px;
14+
margin-top: 4px;
15+
}
16+
</style>
1017
</head>
1118
<body>
1219
<div class="sdpi-wrapper">
20+
<!-- Calendar Selection -->
1321
<div class="sdpi-item">
14-
<div class="sdpi-item-label">iCal URL</div>
15-
<input id="url" class="next-to-settings sdpi-item-value" type="text" value="" readonly />
16-
<button class="settings" id="settings" value="Settings"><span>Settings</span></button>
22+
<div class="sdpi-item-label">Calendar</div>
23+
<select id="calendarSelect" class="sdpi-item-value select">
24+
<option value="">Default Calendar</option>
25+
<!-- Populated dynamically from global settings -->
26+
</select>
27+
</div>
28+
29+
<div class="sdpi-item" id="noCalendarsContainer" style="display: none;">
30+
<div class="sdpi-item-label"></div>
31+
<div class="sdpi-item-value no-calendars-warning">
32+
No calendars configured. Click Settings to add calendars.
33+
</div>
1734
</div>
1835

36+
<hr class="sdpi-separator" style="margin: 10px 0; border-color: #3a3a3a;" />
37+
1938
<div class="sdpi-item">
20-
<div class="sdpi-item-label">Time Window</div>
21-
<select id="timeWindow" class="sdpi-item-value select">
22-
<option value="1">1 Day</option>
23-
<option value="3" selected>3 Days</option>
24-
<option value="5">5 Days</option>
25-
<option value="7">7 Days</option>
26-
</select>
39+
<div class="sdpi-item-label">Settings</div>
40+
<button class="sdpi-item-value" id="settings">⚙️ Manage Calendars & Settings</button>
2741
</div>
2842

2943
<div class="sdpi-item" id="statusContainer" style="display: none;">

0 commit comments

Comments
 (0)