@xiboplayer/schedule 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,351 @@
1
+ /**
2
+ * Integration tests - Schedule + Interrupt Scheduler
3
+ * Tests the integration between @xiboplayer/schedule and interrupt processing
4
+ */
5
+
6
+ import { describe, it, expect, beforeEach } from 'vitest';
7
+ import { ScheduleManager } from '@xiboplayer/schedule';
8
+ import { InterruptScheduler } from './interrupts.js';
9
+
10
+ describe('Schedule + Interrupt Integration', () => {
11
+ let scheduleManager;
12
+ let interruptScheduler;
13
+
14
+ beforeEach(() => {
15
+ interruptScheduler = new InterruptScheduler();
16
+ scheduleManager = new ScheduleManager({ interruptScheduler });
17
+ });
18
+
19
+ // Helper to create mock schedule
20
+ const createSchedule = (layouts) => ({
21
+ layouts: layouts.map((l, idx) => ({
22
+ id: l.id || idx + 1,
23
+ file: l.file || idx + 100,
24
+ duration: l.duration || 60,
25
+ shareOfVoice: l.shareOfVoice || 0,
26
+ priority: l.priority || 0,
27
+ fromdt: l.fromdt || '2026-01-01 00:00:00',
28
+ todt: l.todt || '2027-01-01 00:00:00',
29
+ maxPlaysPerHour: l.maxPlaysPerHour || 0
30
+ }))
31
+ });
32
+
33
+ describe('Basic interrupt integration', () => {
34
+ it('should process interrupts automatically', () => {
35
+ const schedule = createSchedule([
36
+ { file: 10, duration: 60, shareOfVoice: 10 }, // Interrupt
37
+ { file: 20, duration: 60, shareOfVoice: 0 } // Normal
38
+ ]);
39
+
40
+ scheduleManager.setSchedule(schedule);
41
+ const layouts = scheduleManager.getCurrentLayouts();
42
+
43
+ // Should have both interrupts and normal layouts
44
+ expect(layouts.length).toBeGreaterThan(0);
45
+
46
+ const interruptCount = layouts.filter(f => f === 10).length;
47
+ const normalCount = layouts.filter(f => f === 20).length;
48
+
49
+ // 10% = 360s / 60s = 6 interrupt plays
50
+ expect(interruptCount).toBe(6);
51
+
52
+ // Remaining 3240s / 60s = 54 normal plays
53
+ expect(normalCount).toBe(54);
54
+ });
55
+
56
+ it('should work without interrupt scheduler', () => {
57
+ // Create schedule manager without interrupt scheduler
58
+ const basicManager = new ScheduleManager();
59
+ const schedule = createSchedule([
60
+ { file: 10, duration: 60, shareOfVoice: 10 },
61
+ { file: 20, duration: 60, shareOfVoice: 0 }
62
+ ]);
63
+
64
+ basicManager.setSchedule(schedule);
65
+ const layouts = basicManager.getCurrentLayouts();
66
+
67
+ // Without interrupt scheduler, both layouts treated equally
68
+ expect(layouts).toContain(10);
69
+ expect(layouts).toContain(20);
70
+ });
71
+
72
+ it('should handle schedule with only normal layouts', () => {
73
+ const schedule = createSchedule([
74
+ { file: 10, duration: 60, shareOfVoice: 0 },
75
+ { file: 20, duration: 60, shareOfVoice: 0 }
76
+ ]);
77
+
78
+ scheduleManager.setSchedule(schedule);
79
+ const layouts = scheduleManager.getCurrentLayouts();
80
+
81
+ // Should return both files
82
+ expect(layouts).toContain(10);
83
+ expect(layouts).toContain(20);
84
+ });
85
+
86
+ it('should handle schedule with only interrupts', () => {
87
+ const schedule = createSchedule([
88
+ { file: 10, duration: 60, shareOfVoice: 100 }
89
+ ]);
90
+
91
+ scheduleManager.setSchedule(schedule);
92
+ const layouts = scheduleManager.getCurrentLayouts();
93
+
94
+ // Should fill hour with interrupts
95
+ expect(layouts.length).toBeGreaterThan(0);
96
+ expect(layouts.every(f => f === 10)).toBe(true);
97
+ });
98
+ });
99
+
100
+ describe('Priority + Interrupts', () => {
101
+ it('should respect priority before processing interrupts', () => {
102
+ const schedule = createSchedule([
103
+ { file: 10, duration: 60, shareOfVoice: 10, priority: 5 }, // Lower priority interrupt
104
+ { file: 20, duration: 60, shareOfVoice: 0, priority: 10 } // Higher priority normal
105
+ ]);
106
+
107
+ scheduleManager.setSchedule(schedule);
108
+ const layouts = scheduleManager.getCurrentLayouts();
109
+
110
+ // Only priority 10 should be included
111
+ expect(layouts.every(f => f === 20)).toBe(true);
112
+ });
113
+
114
+ it('should process interrupts among same-priority layouts', () => {
115
+ const schedule = createSchedule([
116
+ { file: 10, duration: 60, shareOfVoice: 25, priority: 10 }, // Interrupt
117
+ { file: 20, duration: 60, shareOfVoice: 25, priority: 10 }, // Interrupt
118
+ { file: 30, duration: 60, shareOfVoice: 0, priority: 10 } // Normal
119
+ ]);
120
+
121
+ scheduleManager.setSchedule(schedule);
122
+ const layouts = scheduleManager.getCurrentLayouts();
123
+
124
+ const int1Count = layouts.filter(f => f === 10).length;
125
+ const int2Count = layouts.filter(f => f === 20).length;
126
+ const normalCount = layouts.filter(f => f === 30).length;
127
+
128
+ // Each interrupt: 25% = 900s / 60s = 15 plays
129
+ expect(int1Count).toBe(15);
130
+ expect(int2Count).toBe(15);
131
+
132
+ // Remaining: 1800s / 60s = 30 normal plays
133
+ expect(normalCount).toBe(30);
134
+ });
135
+ });
136
+
137
+ describe('Campaigns + Interrupts', () => {
138
+ it('should process interrupts from campaigns', () => {
139
+ scheduleManager.setSchedule({
140
+ campaigns: [
141
+ {
142
+ id: 1,
143
+ priority: 10,
144
+ fromdt: '2026-01-01 00:00:00',
145
+ todt: '2027-01-01 00:00:00',
146
+ layouts: [
147
+ { file: 10, duration: 60, shareOfVoice: 10 },
148
+ { file: 20, duration: 60, shareOfVoice: 0 }
149
+ ]
150
+ }
151
+ ]
152
+ });
153
+
154
+ const layouts = scheduleManager.getCurrentLayouts();
155
+
156
+ const interruptCount = layouts.filter(f => f === 10).length;
157
+ const normalCount = layouts.filter(f => f === 20).length;
158
+
159
+ // Interrupt: 10% = 360s / 60s = 6 plays
160
+ expect(interruptCount).toBe(6);
161
+
162
+ // Normal fills remaining time
163
+ expect(normalCount).toBeGreaterThan(0);
164
+ });
165
+
166
+ it('should handle mixed campaigns and standalone interrupts', () => {
167
+ scheduleManager.setSchedule({
168
+ campaigns: [
169
+ {
170
+ id: 1,
171
+ priority: 10,
172
+ fromdt: '2026-01-01 00:00:00',
173
+ todt: '2027-01-01 00:00:00',
174
+ layouts: [
175
+ { file: 10, duration: 60, shareOfVoice: 0 } // Normal in campaign
176
+ ]
177
+ }
178
+ ],
179
+ layouts: [
180
+ {
181
+ id: 2,
182
+ file: 20,
183
+ duration: 60,
184
+ shareOfVoice: 10, // Interrupt standalone
185
+ priority: 10,
186
+ fromdt: '2026-01-01 00:00:00',
187
+ todt: '2027-01-01 00:00:00'
188
+ }
189
+ ]
190
+ });
191
+
192
+ const layouts = scheduleManager.getCurrentLayouts();
193
+
194
+ const normalCount = layouts.filter(f => f === 10).length;
195
+ const interruptCount = layouts.filter(f => f === 20).length;
196
+
197
+ // Both should be present
198
+ expect(normalCount).toBeGreaterThan(0);
199
+ expect(interruptCount).toBeGreaterThan(0);
200
+
201
+ // Interrupt: 10% = 6 plays
202
+ expect(interruptCount).toBe(6);
203
+ });
204
+ });
205
+
206
+ describe('Time-based filtering + Interrupts', () => {
207
+ it('should filter out-of-date interrupts', () => {
208
+ const schedule = createSchedule([
209
+ { file: 10, duration: 60, shareOfVoice: 50, todt: '2020-01-01 00:00:00' }, // Expired interrupt
210
+ { file: 20, duration: 60, shareOfVoice: 0 } // Active normal
211
+ ]);
212
+
213
+ scheduleManager.setSchedule(schedule);
214
+ const layouts = scheduleManager.getCurrentLayouts();
215
+
216
+ // Only normal layout should be present
217
+ expect(layouts.every(f => f === 20)).toBe(true);
218
+ });
219
+
220
+ it('should handle dayparting with interrupts', () => {
221
+ const now = new Date();
222
+ const currentHour = now.getHours();
223
+ const fromTime = new Date();
224
+ fromTime.setHours(currentHour - 1, 0, 0, 0);
225
+ const toTime = new Date();
226
+ toTime.setHours(currentHour + 1, 0, 0, 0);
227
+
228
+ scheduleManager.setSchedule({
229
+ layouts: [
230
+ {
231
+ id: 1,
232
+ file: 10,
233
+ duration: 60,
234
+ shareOfVoice: 20,
235
+ priority: 0,
236
+ fromdt: fromTime.toISOString(),
237
+ todt: toTime.toISOString(),
238
+ recurrenceType: 'Week',
239
+ recurrenceRepeatsOn: '1,2,3,4,5,6,7' // All days
240
+ },
241
+ {
242
+ id: 2,
243
+ file: 20,
244
+ duration: 60,
245
+ shareOfVoice: 0,
246
+ priority: 0,
247
+ fromdt: fromTime.toISOString(),
248
+ todt: toTime.toISOString()
249
+ }
250
+ ]
251
+ });
252
+
253
+ const layouts = scheduleManager.getCurrentLayouts();
254
+
255
+ // Should have both layouts
256
+ expect(layouts.length).toBeGreaterThan(0);
257
+
258
+ const interruptCount = layouts.filter(f => f === 10).length;
259
+
260
+ // 20% = 720s / 60s = 12 plays
261
+ expect(interruptCount).toBe(12);
262
+ });
263
+ });
264
+
265
+ describe('maxPlaysPerHour + Interrupts', () => {
266
+ it('should respect maxPlaysPerHour for normal layouts', () => {
267
+ const schedule = createSchedule([
268
+ { file: 10, duration: 60, shareOfVoice: 10 }, // Interrupt
269
+ { file: 20, duration: 60, shareOfVoice: 0, maxPlaysPerHour: 5 } // Normal with limit
270
+ ]);
271
+
272
+ scheduleManager.setSchedule(schedule);
273
+
274
+ // Record plays to trigger maxPlaysPerHour
275
+ for (let i = 0; i < 5; i++) {
276
+ scheduleManager.recordPlay(2); // Layout ID 2
277
+ }
278
+
279
+ const layouts = scheduleManager.getCurrentLayouts();
280
+
281
+ // Normal layout should be filtered out
282
+ const normalCount = layouts.filter(f => f === 20).length;
283
+ expect(normalCount).toBe(0);
284
+
285
+ // Only interrupts should remain
286
+ expect(layouts.every(f => f === 10)).toBe(true);
287
+ });
288
+ });
289
+
290
+ describe('Real-world scenarios', () => {
291
+ it('should handle typical digital signage with 5% ads', () => {
292
+ const schedule = createSchedule([
293
+ { file: 100, duration: 120, shareOfVoice: 5 }, // 5% ads
294
+ { file: 200, duration: 120, shareOfVoice: 0 }, // Content 1
295
+ { file: 300, duration: 120, shareOfVoice: 0 } // Content 2
296
+ ]);
297
+
298
+ scheduleManager.setSchedule(schedule);
299
+ const layouts = scheduleManager.getCurrentLayouts();
300
+
301
+ const adCount = layouts.filter(f => f === 100).length;
302
+ const adDuration = adCount * 120;
303
+
304
+ // 5% of hour = 180 seconds (may overshoot slightly due to rounding)
305
+ expect(adDuration).toBeGreaterThanOrEqual(180);
306
+ expect(adDuration).toBeLessThan(300); // Reasonable upper bound
307
+
308
+ // Should have content layouts
309
+ expect(layouts.filter(f => f === 200).length).toBeGreaterThan(0);
310
+ expect(layouts.filter(f => f === 300).length).toBeGreaterThan(0);
311
+ });
312
+
313
+ it('should handle lunch hour with special promotions', () => {
314
+ const schedule = createSchedule([
315
+ { file: 1000, duration: 30, shareOfVoice: 20 }, // Lunch specials: 20%
316
+ { file: 2000, duration: 120, shareOfVoice: 0 } // Regular menu
317
+ ]);
318
+
319
+ scheduleManager.setSchedule(schedule);
320
+ const layouts = scheduleManager.getCurrentLayouts();
321
+
322
+ const specialCount = layouts.filter(f => f === 1000).length;
323
+ const specialDuration = specialCount * 30;
324
+
325
+ // 20% of hour = 720 seconds
326
+ expect(specialDuration).toBe(720);
327
+
328
+ // Should have regular content
329
+ expect(layouts.filter(f => f === 2000).length).toBeGreaterThan(0);
330
+ });
331
+
332
+ it('should handle emergency alerts (high shareOfVoice)', () => {
333
+ const schedule = createSchedule([
334
+ { file: 9999, duration: 60, shareOfVoice: 75 }, // Emergency alert: 75%
335
+ { file: 1000, duration: 60, shareOfVoice: 0 } // Normal content
336
+ ]);
337
+
338
+ scheduleManager.setSchedule(schedule);
339
+ const layouts = scheduleManager.getCurrentLayouts();
340
+
341
+ const alertCount = layouts.filter(f => f === 9999).length;
342
+ const contentCount = layouts.filter(f => f === 1000).length;
343
+
344
+ // Alert: 75% = 2700s / 60s = 45 plays
345
+ expect(alertCount).toBe(45);
346
+
347
+ // Content: 25% = 900s / 60s = 15 plays
348
+ expect(contentCount).toBe(15);
349
+ });
350
+ });
351
+ });
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Interrupt Layout Scheduler (Share of Voice)
3
+ *
4
+ * Implements the shareOfVoice algorithm from upstream electron-player.
5
+ * Interrupts are layouts that must play for a percentage of each hour.
6
+ *
7
+ * Algorithm:
8
+ * 1. Separate interrupts from normal layouts
9
+ * 2. Calculate how many times each interrupt must play per hour
10
+ * 3. Fill remaining time with normal layouts
11
+ * 4. Interleave interrupts and normal layouts evenly
12
+ *
13
+ * Based on: electron-player/src/main/common/scheduleManager.ts (lines 181-321)
14
+ */
15
+
16
+ import { createLogger } from '@xiboplayer/utils';
17
+
18
+ const logger = createLogger('schedule:interrupts');
19
+
20
+ /**
21
+ * Interrupt Scheduler
22
+ * Handles shareOfVoice layouts that must play for a percentage of each hour
23
+ */
24
+ export class InterruptScheduler {
25
+ constructor() {
26
+ // Track committed duration per interrupt layout
27
+ this.interruptCommittedDurations = new Map(); // layoutId -> seconds
28
+ }
29
+
30
+ /**
31
+ * Check if a layout is an interrupt (has shareOfVoice > 0)
32
+ * @param {Object} layout - Layout object with shareOfVoice property
33
+ * @returns {boolean} True if layout is an interrupt
34
+ */
35
+ isInterrupt(layout) {
36
+ return !!(layout.shareOfVoice && layout.shareOfVoice > 0);
37
+ }
38
+
39
+ /**
40
+ * Reset committed duration tracking (call this every hour)
41
+ */
42
+ resetCommittedDurations() {
43
+ this.interruptCommittedDurations.clear();
44
+ logger.debug('Reset interrupt committed durations');
45
+ }
46
+
47
+ /**
48
+ * Get committed duration for a layout
49
+ * @param {string} layoutId - Layout ID
50
+ * @returns {number} Committed duration in seconds
51
+ */
52
+ getCommittedDuration(layoutId) {
53
+ return this.interruptCommittedDurations.get(layoutId) || 0;
54
+ }
55
+
56
+ /**
57
+ * Add committed duration for a layout
58
+ * @param {string} layoutId - Layout ID
59
+ * @param {number} duration - Duration to add in seconds
60
+ */
61
+ addCommittedDuration(layoutId, duration) {
62
+ const current = this.getCommittedDuration(layoutId);
63
+ this.interruptCommittedDurations.set(layoutId, current + duration);
64
+ }
65
+
66
+ /**
67
+ * Check if interrupt layout has satisfied its shareOfVoice requirement
68
+ * @param {Object} layout - Layout with shareOfVoice and duration
69
+ * @returns {boolean} True if satisfied
70
+ */
71
+ isInterruptDurationSatisfied(layout) {
72
+ if (!layout.shareOfVoice) {
73
+ return true; // Not an interrupt
74
+ }
75
+
76
+ const layoutId = layout.id || layout.file;
77
+ const requiredSeconds = (layout.shareOfVoice / 100) * 3600; // shareOfVoice is percentage
78
+ const committedSeconds = this.getCommittedDuration(layoutId);
79
+
80
+ return committedSeconds >= requiredSeconds;
81
+ }
82
+
83
+ /**
84
+ * Calculate how many seconds this interrupt needs to play per hour
85
+ * @param {Object} layout - Layout with shareOfVoice
86
+ * @returns {number} Required seconds per hour
87
+ */
88
+ getRequiredSeconds(layout) {
89
+ if (!layout.shareOfVoice) {
90
+ return 0;
91
+ }
92
+ return (layout.shareOfVoice / 100) * 3600;
93
+ }
94
+
95
+ /**
96
+ * Process interrupt layouts and combine with normal layouts
97
+ * Implements the shareOfVoice algorithm from upstream
98
+ *
99
+ * @param {Array} normalLayouts - Normal scheduled layouts
100
+ * @param {Array} interruptLayouts - Interrupt layouts with shareOfVoice
101
+ * @returns {Array} Combined layout loop for the hour
102
+ */
103
+ processInterrupts(normalLayouts, interruptLayouts) {
104
+ if (!interruptLayouts || interruptLayouts.length === 0) {
105
+ logger.debug('No interrupt layouts, returning normal layouts');
106
+ return normalLayouts;
107
+ }
108
+
109
+ if (!normalLayouts || normalLayouts.length === 0) {
110
+ logger.warn('No normal layouts available, interrupts will fill entire hour');
111
+ return this.fillHourWithInterrupts(interruptLayouts);
112
+ }
113
+
114
+ logger.info(`Processing ${interruptLayouts.length} interrupt layouts with ${normalLayouts.length} normal layouts`);
115
+
116
+ // Reset committed durations for this calculation
117
+ for (const layout of interruptLayouts) {
118
+ const layoutId = layout.id || layout.file;
119
+ this.interruptCommittedDurations.set(layoutId, 0);
120
+ }
121
+
122
+ const resolvedInterruptLayouts = [];
123
+ let interruptSecondsInHour = 0;
124
+ let index = 0;
125
+ let satisfied = false;
126
+
127
+ // Step 1: Build interrupt loop by cycling through interrupts until all are satisfied
128
+ while (!satisfied) {
129
+ // Gone all the way around? Check if all satisfied
130
+ if (index >= interruptLayouts.length) {
131
+ index = 0;
132
+
133
+ // Check if all interrupts are satisfied
134
+ let allSatisfied = true;
135
+ for (const layout of interruptLayouts) {
136
+ if (!this.isInterruptDurationSatisfied(layout)) {
137
+ allSatisfied = false;
138
+ break;
139
+ }
140
+ }
141
+
142
+ if (allSatisfied) {
143
+ satisfied = true;
144
+ break;
145
+ }
146
+ }
147
+
148
+ const currentInterrupt = interruptLayouts[index];
149
+
150
+ // If this interrupt is not satisfied, add it to the loop
151
+ if (!this.isInterruptDurationSatisfied(currentInterrupt)) {
152
+ const layoutId = currentInterrupt.id || currentInterrupt.file;
153
+ this.addCommittedDuration(layoutId, currentInterrupt.duration);
154
+ interruptSecondsInHour += currentInterrupt.duration;
155
+ resolvedInterruptLayouts.push(currentInterrupt);
156
+ }
157
+
158
+ index++;
159
+ }
160
+
161
+ logger.debug(`Resolved ${resolvedInterruptLayouts.length} interrupt plays (${interruptSecondsInHour}s total)`);
162
+
163
+ // Step 2: If interrupts fill the entire hour, return only interrupts
164
+ if (interruptSecondsInHour >= 3600) {
165
+ logger.info('Interrupts fill entire hour (>= 3600s), no room for normal layouts');
166
+ return resolvedInterruptLayouts;
167
+ }
168
+
169
+ // Step 3: Fill remaining time with normal layouts
170
+ const normalSecondsInHour = 3600 - interruptSecondsInHour;
171
+ const resolvedNormalLayouts = this.fillTimeWithLayouts(normalLayouts, normalSecondsInHour);
172
+
173
+ logger.debug(`Resolved ${resolvedNormalLayouts.length} normal plays (${normalSecondsInHour}s target)`);
174
+
175
+ // Step 4: Interleave interrupts and normal layouts
176
+ const loop = this.interleaveLayouts(resolvedNormalLayouts, resolvedInterruptLayouts);
177
+
178
+ logger.info(`Final loop: ${loop.length} layouts (${resolvedNormalLayouts.length} normal + ${resolvedInterruptLayouts.length} interrupts)`);
179
+
180
+ return loop;
181
+ }
182
+
183
+ /**
184
+ * Fill time with layouts by repeating them until duration is reached
185
+ * @param {Array} layouts - Layouts to use
186
+ * @param {number} targetSeconds - Target duration in seconds
187
+ * @returns {Array} Resolved layout array
188
+ */
189
+ fillTimeWithLayouts(layouts, targetSeconds) {
190
+ const resolved = [];
191
+ let remainingSeconds = targetSeconds;
192
+ let index = 0;
193
+
194
+ while (remainingSeconds > 0) {
195
+ if (index >= layouts.length) {
196
+ index = 0; // Loop back
197
+ }
198
+
199
+ const layout = layouts[index];
200
+ resolved.push(layout);
201
+ remainingSeconds -= layout.duration;
202
+ index++;
203
+ }
204
+
205
+ return resolved;
206
+ }
207
+
208
+ /**
209
+ * Fill entire hour with interrupt layouts only
210
+ * @param {Array} interruptLayouts - Interrupt layouts
211
+ * @returns {Array} Layout loop
212
+ */
213
+ fillHourWithInterrupts(interruptLayouts) {
214
+ return this.fillTimeWithLayouts(interruptLayouts, 3600);
215
+ }
216
+
217
+ /**
218
+ * Interleave normal and interrupt layouts evenly
219
+ * Based on upstream algorithm (scheduleManager.ts lines 268-316)
220
+ *
221
+ * @param {Array} normalLayouts - Normal layouts
222
+ * @param {Array} interruptLayouts - Interrupt layouts
223
+ * @returns {Array} Interleaved layout array
224
+ */
225
+ interleaveLayouts(normalLayouts, interruptLayouts) {
226
+ const loop = [];
227
+ const pickCount = Math.max(normalLayouts.length, interruptLayouts.length);
228
+
229
+ // Calculate pick intervals
230
+ // Normal: ceiling (pick more often from normal)
231
+ // Interrupt: floor (pick less often from interrupts)
232
+ const normalPick = Math.ceil(1.0 * pickCount / normalLayouts.length);
233
+ const interruptPick = Math.floor(1.0 * pickCount / interruptLayouts.length);
234
+
235
+ logger.debug(`Interleaving: pickCount=${pickCount}, normalPick=${normalPick}, interruptPick=${interruptPick}`);
236
+
237
+ let normalIndex = 0;
238
+ let interruptIndex = 0;
239
+ let totalSecondsAllocated = 0;
240
+
241
+ for (let i = 0; i < pickCount; i++) {
242
+ // Pick from normal list
243
+ if (i % normalPick === 0) {
244
+ // Allow wrapping around
245
+ if (normalIndex >= normalLayouts.length) {
246
+ normalIndex = 0;
247
+ }
248
+ loop.push(normalLayouts[normalIndex]);
249
+ totalSecondsAllocated += normalLayouts[normalIndex].duration;
250
+ normalIndex++;
251
+ }
252
+
253
+ // Pick from interrupt list (only if we haven't picked them all yet)
254
+ if (i % interruptPick === 0 && interruptIndex < interruptLayouts.length) {
255
+ loop.push(interruptLayouts[interruptIndex]);
256
+ totalSecondsAllocated += interruptLayouts[interruptIndex].duration;
257
+ interruptIndex++;
258
+ }
259
+ }
260
+
261
+ // Fill remaining time with normal layouts (due to ceiling/floor rounding)
262
+ while (totalSecondsAllocated < 3600) {
263
+ if (normalIndex >= normalLayouts.length) {
264
+ normalIndex = 0;
265
+ }
266
+ loop.push(normalLayouts[normalIndex]);
267
+ totalSecondsAllocated += normalLayouts[normalIndex].duration;
268
+ normalIndex++;
269
+ }
270
+
271
+ logger.debug(`Interleaved ${loop.length} layouts, total duration: ${totalSecondsAllocated}s`);
272
+
273
+ return loop;
274
+ }
275
+
276
+ /**
277
+ * Separate layouts into normal and interrupt arrays
278
+ * @param {Array} layouts - All layouts
279
+ * @returns {Object} { normalLayouts, interruptLayouts }
280
+ */
281
+ separateLayouts(layouts) {
282
+ const normalLayouts = [];
283
+ const interruptLayouts = [];
284
+
285
+ for (const layout of layouts) {
286
+ if (this.isInterrupt(layout)) {
287
+ interruptLayouts.push(layout);
288
+ } else {
289
+ normalLayouts.push(layout);
290
+ }
291
+ }
292
+
293
+ return { normalLayouts, interruptLayouts };
294
+ }
295
+ }
296
+
297
+ // Export singleton instance for convenience
298
+ export const interruptScheduler = new InterruptScheduler();