@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,482 @@
1
+ /**
2
+ * Interrupt Scheduler Tests
3
+ * Exhaustive test suite for shareOfVoice interrupt layouts
4
+ */
5
+
6
+ import { describe, it, expect, beforeEach } from 'vitest';
7
+ import { InterruptScheduler } from './interrupts.js';
8
+
9
+ describe('InterruptScheduler', () => {
10
+ let scheduler;
11
+
12
+ beforeEach(() => {
13
+ scheduler = new InterruptScheduler();
14
+ });
15
+
16
+ // Helper to create mock layouts
17
+ const createLayout = (id, duration, shareOfVoice = 0) => ({
18
+ id,
19
+ file: id,
20
+ duration,
21
+ shareOfVoice,
22
+ });
23
+
24
+ describe('isInterrupt', () => {
25
+ it('should identify interrupt layouts', () => {
26
+ const interrupt = createLayout('int1', 10, 50);
27
+ const normal = createLayout('norm1', 10, 0);
28
+
29
+ expect(scheduler.isInterrupt(interrupt)).toBe(true);
30
+ expect(scheduler.isInterrupt(normal)).toBe(false);
31
+ });
32
+
33
+ it('should handle missing shareOfVoice', () => {
34
+ const layout = { id: 'test', duration: 10 };
35
+ expect(scheduler.isInterrupt(layout)).toBe(false);
36
+ });
37
+ });
38
+
39
+ describe('getRequiredSeconds', () => {
40
+ it('should calculate required seconds from shareOfVoice percentage', () => {
41
+ expect(scheduler.getRequiredSeconds(createLayout('int1', 10, 10))).toBe(360); // 10% of 3600
42
+ expect(scheduler.getRequiredSeconds(createLayout('int2', 10, 50))).toBe(1800); // 50% of 3600
43
+ expect(scheduler.getRequiredSeconds(createLayout('int3', 10, 100))).toBe(3600); // 100% of 3600
44
+ });
45
+
46
+ it('should return 0 for non-interrupt layouts', () => {
47
+ expect(scheduler.getRequiredSeconds(createLayout('norm1', 10, 0))).toBe(0);
48
+ });
49
+ });
50
+
51
+ describe('isInterruptDurationSatisfied', () => {
52
+ it('should check if interrupt has met its shareOfVoice requirement', () => {
53
+ const layout = createLayout('int1', 60, 10); // 10% = 360s required
54
+
55
+ expect(scheduler.isInterruptDurationSatisfied(layout)).toBe(false);
56
+
57
+ // Add some duration
58
+ scheduler.addCommittedDuration('int1', 180);
59
+ expect(scheduler.isInterruptDurationSatisfied(layout)).toBe(false); // 180 < 360
60
+
61
+ // Add more to reach requirement
62
+ scheduler.addCommittedDuration('int1', 180);
63
+ expect(scheduler.isInterruptDurationSatisfied(layout)).toBe(true); // 360 >= 360
64
+ });
65
+
66
+ it('should always return true for non-interrupt layouts', () => {
67
+ const layout = createLayout('norm1', 10, 0);
68
+ expect(scheduler.isInterruptDurationSatisfied(layout)).toBe(true);
69
+ });
70
+ });
71
+
72
+ describe('resetCommittedDurations', () => {
73
+ it('should clear all committed durations', () => {
74
+ scheduler.addCommittedDuration('int1', 100);
75
+ scheduler.addCommittedDuration('int2', 200);
76
+
77
+ expect(scheduler.getCommittedDuration('int1')).toBe(100);
78
+ expect(scheduler.getCommittedDuration('int2')).toBe(200);
79
+
80
+ scheduler.resetCommittedDurations();
81
+
82
+ expect(scheduler.getCommittedDuration('int1')).toBe(0);
83
+ expect(scheduler.getCommittedDuration('int2')).toBe(0);
84
+ });
85
+ });
86
+
87
+ describe('separateLayouts', () => {
88
+ it('should separate normal and interrupt layouts', () => {
89
+ const layouts = [
90
+ createLayout('norm1', 10, 0),
91
+ createLayout('int1', 10, 50),
92
+ createLayout('norm2', 20, 0),
93
+ createLayout('int2', 10, 25),
94
+ ];
95
+
96
+ const { normalLayouts, interruptLayouts } = scheduler.separateLayouts(layouts);
97
+
98
+ expect(normalLayouts).toHaveLength(2);
99
+ expect(interruptLayouts).toHaveLength(2);
100
+ expect(normalLayouts[0].id).toBe('norm1');
101
+ expect(normalLayouts[1].id).toBe('norm2');
102
+ expect(interruptLayouts[0].id).toBe('int1');
103
+ expect(interruptLayouts[1].id).toBe('int2');
104
+ });
105
+
106
+ it('should handle all normal layouts', () => {
107
+ const layouts = [
108
+ createLayout('norm1', 10, 0),
109
+ createLayout('norm2', 20, 0),
110
+ ];
111
+
112
+ const { normalLayouts, interruptLayouts } = scheduler.separateLayouts(layouts);
113
+
114
+ expect(normalLayouts).toHaveLength(2);
115
+ expect(interruptLayouts).toHaveLength(0);
116
+ });
117
+
118
+ it('should handle all interrupt layouts', () => {
119
+ const layouts = [
120
+ createLayout('int1', 10, 50),
121
+ createLayout('int2', 10, 25),
122
+ ];
123
+
124
+ const { normalLayouts, interruptLayouts } = scheduler.separateLayouts(layouts);
125
+
126
+ expect(normalLayouts).toHaveLength(0);
127
+ expect(interruptLayouts).toHaveLength(2);
128
+ });
129
+ });
130
+
131
+ describe('fillTimeWithLayouts', () => {
132
+ it('should repeat layouts to fill target duration', () => {
133
+ const layouts = [
134
+ createLayout('l1', 100),
135
+ createLayout('l2', 100),
136
+ ];
137
+
138
+ const result = scheduler.fillTimeWithLayouts(layouts, 500);
139
+
140
+ // Should cycle through: l1, l2, l1, l2, l1 = 500s
141
+ expect(result).toHaveLength(5);
142
+ expect(result[0].id).toBe('l1');
143
+ expect(result[1].id).toBe('l2');
144
+ expect(result[2].id).toBe('l1');
145
+ expect(result[3].id).toBe('l2');
146
+ expect(result[4].id).toBe('l1');
147
+ });
148
+
149
+ it('should handle single layout', () => {
150
+ const layouts = [createLayout('l1', 60)];
151
+ const result = scheduler.fillTimeWithLayouts(layouts, 180);
152
+
153
+ expect(result).toHaveLength(3); // 60s * 3 = 180s
154
+ expect(result.every(l => l.id === 'l1')).toBe(true);
155
+ });
156
+
157
+ it('should overshoot slightly when layouts do not divide evenly', () => {
158
+ const layouts = [createLayout('l1', 70)];
159
+ const result = scheduler.fillTimeWithLayouts(layouts, 200);
160
+
161
+ // Will overshoot: 70 * 3 = 210 > 200
162
+ expect(result).toHaveLength(3);
163
+ });
164
+ });
165
+
166
+ describe('processInterrupts - basic scenarios', () => {
167
+ it('should return normal layouts when no interrupts', () => {
168
+ const normal = [
169
+ createLayout('norm1', 100),
170
+ createLayout('norm2', 100),
171
+ ];
172
+
173
+ const result = scheduler.processInterrupts(normal, []);
174
+
175
+ expect(result).toEqual(normal);
176
+ });
177
+
178
+ it('should handle 10% shareOfVoice interrupt', () => {
179
+ const normal = [createLayout('norm1', 60)];
180
+ const interrupts = [createLayout('int1', 60, 10)]; // 10% = 360s
181
+
182
+ const result = scheduler.processInterrupts(normal, interrupts);
183
+
184
+ // Count how many times each layout appears
185
+ const interruptCount = result.filter(l => l.id === 'int1').length;
186
+ const normalCount = result.filter(l => l.id === 'norm1').length;
187
+
188
+ // 360s / 60s = 6 interrupt plays
189
+ expect(interruptCount).toBe(6);
190
+
191
+ // Remaining: 3240s / 60s = 54 normal plays
192
+ expect(normalCount).toBe(54);
193
+ });
194
+
195
+ it('should handle 50% shareOfVoice interrupt', () => {
196
+ const normal = [createLayout('norm1', 60)];
197
+ const interrupts = [createLayout('int1', 60, 50)]; // 50% = 1800s
198
+
199
+ const result = scheduler.processInterrupts(normal, interrupts);
200
+
201
+ const interruptCount = result.filter(l => l.id === 'int1').length;
202
+ const normalCount = result.filter(l => l.id === 'norm1').length;
203
+
204
+ // 1800s / 60s = 30 interrupt plays
205
+ expect(interruptCount).toBe(30);
206
+
207
+ // Remaining: 1800s / 60s = 30 normal plays
208
+ expect(normalCount).toBe(30);
209
+ });
210
+
211
+ it('should handle 100% shareOfVoice interrupt (fills entire hour)', () => {
212
+ const normal = [createLayout('norm1', 60)];
213
+ const interrupts = [createLayout('int1', 60, 100)]; // 100% = 3600s
214
+
215
+ const result = scheduler.processInterrupts(normal, interrupts);
216
+
217
+ const interruptCount = result.filter(l => l.id === 'int1').length;
218
+ const normalCount = result.filter(l => l.id === 'norm1').length;
219
+
220
+ // 3600s / 60s = 60 interrupt plays
221
+ expect(interruptCount).toBe(60);
222
+
223
+ // No room for normal layouts
224
+ expect(normalCount).toBe(0);
225
+ });
226
+ });
227
+
228
+ describe('processInterrupts - multiple interrupts', () => {
229
+ it('should handle two interrupts with different shareOfVoice', () => {
230
+ const normal = [createLayout('norm1', 60)];
231
+ const interrupts = [
232
+ createLayout('int1', 60, 25), // 25% = 900s
233
+ createLayout('int2', 60, 25), // 25% = 900s
234
+ ];
235
+
236
+ const result = scheduler.processInterrupts(normal, interrupts);
237
+
238
+ const int1Count = result.filter(l => l.id === 'int1').length;
239
+ const int2Count = result.filter(l => l.id === 'int2').length;
240
+ const normalCount = result.filter(l => l.id === 'norm1').length;
241
+
242
+ // Each interrupt: 900s / 60s = 15 plays
243
+ expect(int1Count).toBe(15);
244
+ expect(int2Count).toBe(15);
245
+
246
+ // Remaining: 1800s / 60s = 30 normal plays
247
+ expect(normalCount).toBe(30);
248
+ });
249
+
250
+ it('should handle interrupts that exceed 100% total (fill entire hour)', () => {
251
+ const normal = [createLayout('norm1', 60)];
252
+ const interrupts = [
253
+ createLayout('int1', 60, 60), // 60%
254
+ createLayout('int2', 60, 60), // 60%
255
+ ];
256
+
257
+ const result = scheduler.processInterrupts(normal, interrupts);
258
+
259
+ const normalCount = result.filter(l => l.id === 'norm1').length;
260
+
261
+ // Total > 100%, so no room for normal layouts
262
+ expect(normalCount).toBe(0);
263
+ });
264
+ });
265
+
266
+ describe('processInterrupts - edge cases', () => {
267
+ it('should handle shareOfVoice = 0 (treated as normal layout)', () => {
268
+ const normal = [createLayout('norm1', 60)];
269
+ const interrupts = [createLayout('int1', 60, 0)]; // 0% = not really an interrupt
270
+
271
+ const result = scheduler.processInterrupts(normal, interrupts);
272
+
273
+ // With shareOfVoice=0, it's not treated as interrupt, but it's still in interrupts array
274
+ // The function will process it and try to satisfy 0% requirement (which is already satisfied)
275
+ // So it won't be added to interrupt loop, result is filled with normal layouts
276
+ const normalCount = result.filter(l => l.id === 'norm1').length;
277
+ expect(normalCount).toBeGreaterThan(0); // Should be filled with normal layouts
278
+ });
279
+
280
+ it('should handle empty normal layouts (interrupts fill hour)', () => {
281
+ const normal = [];
282
+ const interrupts = [createLayout('int1', 60, 50)]; // 50%
283
+
284
+ const result = scheduler.processInterrupts(normal, interrupts);
285
+
286
+ // Should fill entire hour with interrupts
287
+ expect(result.length).toBeGreaterThan(0);
288
+ expect(result.every(l => l.id === 'int1')).toBe(true);
289
+ });
290
+
291
+ it('should handle different layout durations', () => {
292
+ const normal = [createLayout('norm1', 120)]; // 2 min layouts
293
+ const interrupts = [createLayout('int1', 30, 10)]; // 30s layouts, 10%
294
+
295
+ const result = scheduler.processInterrupts(normal, interrupts);
296
+
297
+ const interruptCount = result.filter(l => l.id === 'int1').length;
298
+
299
+ // 360s / 30s = 12 interrupt plays
300
+ expect(interruptCount).toBe(12);
301
+ });
302
+ });
303
+
304
+ describe('processInterrupts - interleaving', () => {
305
+ it('should interleave interrupts evenly with normal layouts', () => {
306
+ const normal = [createLayout('norm1', 60)];
307
+ const interrupts = [createLayout('int1', 60, 10)]; // 10%
308
+
309
+ const result = scheduler.processInterrupts(normal, interrupts);
310
+
311
+ // Check that interrupts are distributed (not all at start or end)
312
+ const firstHalf = result.slice(0, result.length / 2);
313
+ const secondHalf = result.slice(result.length / 2);
314
+
315
+ const firstHalfInterrupts = firstHalf.filter(l => l.id === 'int1').length;
316
+ const secondHalfInterrupts = secondHalf.filter(l => l.id === 'int1').length;
317
+
318
+ // Both halves should have some interrupts (roughly equal)
319
+ expect(firstHalfInterrupts).toBeGreaterThan(0);
320
+ expect(secondHalfInterrupts).toBeGreaterThan(0);
321
+ });
322
+
323
+ it('should maintain order within normal layouts during interleaving', () => {
324
+ const normal = [
325
+ createLayout('norm1', 60),
326
+ createLayout('norm2', 60),
327
+ ];
328
+ const interrupts = [createLayout('int1', 60, 10)]; // 10%
329
+
330
+ const result = scheduler.processInterrupts(normal, interrupts);
331
+
332
+ // Extract just the normal layouts from result
333
+ const normalInResult = result.filter(l => l.id.startsWith('norm'));
334
+
335
+ // Should cycle norm1, norm2, norm1, norm2, ...
336
+ for (let i = 0; i < normalInResult.length - 1; i += 2) {
337
+ if (normalInResult[i]) {
338
+ expect(normalInResult[i].id).toBe('norm1');
339
+ }
340
+ if (normalInResult[i + 1]) {
341
+ expect(normalInResult[i + 1].id).toBe('norm2');
342
+ }
343
+ }
344
+ });
345
+ });
346
+
347
+ describe('processInterrupts - duration validation', () => {
348
+ it('should produce loop that approximates 3600 seconds', () => {
349
+ const normal = [createLayout('norm1', 60)];
350
+ const interrupts = [createLayout('int1', 60, 25)]; // 25%
351
+
352
+ const result = scheduler.processInterrupts(normal, interrupts);
353
+
354
+ const totalDuration = result.reduce((sum, layout) => sum + layout.duration, 0);
355
+
356
+ // Should be close to 3600s (may overshoot slightly)
357
+ expect(totalDuration).toBeGreaterThanOrEqual(3600);
358
+ expect(totalDuration).toBeLessThan(3700); // Allow small overshoot
359
+ });
360
+
361
+ it('should handle hour boundary correctly', () => {
362
+ const normal = [createLayout('norm1', 3600)]; // 1 hour layout
363
+ const interrupts = [createLayout('int1', 60, 10)]; // 10% = 6 minutes
364
+
365
+ const result = scheduler.processInterrupts(normal, interrupts);
366
+
367
+ // Should have 6 minutes of interrupts + remaining time from normal
368
+ const interruptTime = result.filter(l => l.id === 'int1')
369
+ .reduce((sum, l) => sum + l.duration, 0);
370
+
371
+ expect(interruptTime).toBe(360); // 6 minutes
372
+ });
373
+ });
374
+
375
+ describe('processInterrupts - campaign-like behavior', () => {
376
+ it('should cycle through multiple normal layouts', () => {
377
+ const normal = [
378
+ createLayout('norm1', 60),
379
+ createLayout('norm2', 60),
380
+ createLayout('norm3', 60),
381
+ ];
382
+ const interrupts = [createLayout('int1', 60, 10)]; // 10%
383
+
384
+ const result = scheduler.processInterrupts(normal, interrupts);
385
+
386
+ const normalInResult = result.filter(l => l.id.startsWith('norm'));
387
+
388
+ // Should see all three normal layouts
389
+ expect(normalInResult.some(l => l.id === 'norm1')).toBe(true);
390
+ expect(normalInResult.some(l => l.id === 'norm2')).toBe(true);
391
+ expect(normalInResult.some(l => l.id === 'norm3')).toBe(true);
392
+ });
393
+
394
+ it('should cycle through multiple interrupt layouts', () => {
395
+ const normal = [createLayout('norm1', 60)];
396
+ const interrupts = [
397
+ createLayout('int1', 60, 10),
398
+ createLayout('int2', 60, 10),
399
+ createLayout('int3', 60, 10),
400
+ ];
401
+
402
+ const result = scheduler.processInterrupts(normal, interrupts);
403
+
404
+ const interruptsInResult = result.filter(l => l.id.startsWith('int'));
405
+
406
+ // Should see all three interrupts
407
+ expect(interruptsInResult.some(l => l.id === 'int1')).toBe(true);
408
+ expect(interruptsInResult.some(l => l.id === 'int2')).toBe(true);
409
+ expect(interruptsInResult.some(l => l.id === 'int3')).toBe(true);
410
+ });
411
+ });
412
+
413
+ describe('processInterrupts - real-world scenarios', () => {
414
+ it('should handle typical advertising scenario (5% ads)', () => {
415
+ const normal = [
416
+ createLayout('content1', 120),
417
+ createLayout('content2', 120),
418
+ ];
419
+ const interrupts = [
420
+ createLayout('ad1', 30, 5), // 5% = 3 minutes of ads per hour
421
+ ];
422
+
423
+ const result = scheduler.processInterrupts(normal, interrupts);
424
+
425
+ const adDuration = result.filter(l => l.id === 'ad1')
426
+ .reduce((sum, l) => sum + l.duration, 0);
427
+
428
+ // Should be 180s (3 minutes)
429
+ expect(adDuration).toBe(180);
430
+ });
431
+
432
+ it('should handle multiple ad campaigns with different shareOfVoice', () => {
433
+ const normal = [createLayout('content1', 60)];
434
+ const interrupts = [
435
+ createLayout('premium_ad', 30, 15), // Premium ads: 15%
436
+ createLayout('regular_ad', 30, 10), // Regular ads: 10%
437
+ ];
438
+
439
+ const result = scheduler.processInterrupts(normal, interrupts);
440
+
441
+ const premiumDuration = result.filter(l => l.id === 'premium_ad')
442
+ .reduce((sum, l) => sum + l.duration, 0);
443
+ const regularDuration = result.filter(l => l.id === 'regular_ad')
444
+ .reduce((sum, l) => sum + l.duration, 0);
445
+
446
+ // Premium: 15% of 3600 = 540s
447
+ expect(premiumDuration).toBe(540);
448
+
449
+ // Regular: 10% of 3600 = 360s
450
+ expect(regularDuration).toBe(360);
451
+ });
452
+
453
+ it('should work with dayparting schedules', () => {
454
+ // Simulate lunch hour with restaurant ads
455
+ const normal = [createLayout('menu', 120)];
456
+ const interrupts = [createLayout('lunch_special', 30, 20)]; // 20% during lunch
457
+
458
+ const result = scheduler.processInterrupts(normal, interrupts);
459
+
460
+ const specialDuration = result.filter(l => l.id === 'lunch_special')
461
+ .reduce((sum, l) => sum + l.duration, 0);
462
+
463
+ // 20% of hour = 720s (12 minutes)
464
+ expect(specialDuration).toBe(720);
465
+ });
466
+ });
467
+
468
+ describe('committed duration tracking', () => {
469
+ it('should track committed durations across multiple layouts', () => {
470
+ scheduler.addCommittedDuration('layout1', 100);
471
+ scheduler.addCommittedDuration('layout2', 200);
472
+ scheduler.addCommittedDuration('layout1', 50);
473
+
474
+ expect(scheduler.getCommittedDuration('layout1')).toBe(150);
475
+ expect(scheduler.getCommittedDuration('layout2')).toBe(200);
476
+ });
477
+
478
+ it('should return 0 for unknown layouts', () => {
479
+ expect(scheduler.getCommittedDuration('unknown')).toBe(0);
480
+ });
481
+ });
482
+ });
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Overlay Layout Scheduler
3
+ *
4
+ * Manages overlay layouts that appear on top of main layouts.
5
+ * Based on upstream electron-player implementation.
6
+ *
7
+ * Overlays:
8
+ * - Render on top of main layout (higher z-index)
9
+ * - Have scheduled start/end times
10
+ * - Support priority ordering (multiple overlays)
11
+ * - Support criteria-based display (future)
12
+ * - Support geofencing (future)
13
+ *
14
+ * Reference: upstream_players/electron-player/src/main/xmds/response/schedule/events/overlayLayout.ts
15
+ */
16
+
17
+ import { createLogger } from '@xiboplayer/utils';
18
+ import { evaluateCriteria } from './criteria.js';
19
+
20
+ const logger = createLogger('schedule:overlays');
21
+
22
+ /**
23
+ * Overlay Scheduler
24
+ * Handles overlay layouts that display on top of main layouts
25
+ */
26
+ export class OverlayScheduler {
27
+ constructor() {
28
+ this.overlays = [];
29
+ this.displayProperties = {};
30
+ this.scheduleManager = null; // Reference to ScheduleManager for geo checks
31
+ logger.debug('OverlayScheduler initialized');
32
+ }
33
+
34
+ /**
35
+ * Set reference to ScheduleManager for geo-fence checks
36
+ * @param {ScheduleManager} scheduleManager
37
+ */
38
+ setScheduleManager(scheduleManager) {
39
+ this.scheduleManager = scheduleManager;
40
+ }
41
+
42
+ /**
43
+ * Set display properties for criteria evaluation
44
+ * @param {Object} properties
45
+ */
46
+ setDisplayProperties(properties) {
47
+ this.displayProperties = properties || {};
48
+ }
49
+
50
+ /**
51
+ * Update overlays from XMDS Schedule response
52
+ * @param {Array} overlays - Overlay objects from XMDS
53
+ */
54
+ setOverlays(overlays) {
55
+ this.overlays = overlays || [];
56
+ logger.info(`Loaded ${this.overlays.length} overlay(s)`);
57
+ }
58
+
59
+ /**
60
+ * Get currently active overlays
61
+ * @returns {Array} Active overlay objects sorted by priority (highest first)
62
+ */
63
+ getCurrentOverlays() {
64
+ if (!this.overlays || this.overlays.length === 0) {
65
+ return [];
66
+ }
67
+
68
+ const now = new Date();
69
+ const activeOverlays = [];
70
+
71
+ for (const overlay of this.overlays) {
72
+ // Check time window
73
+ if (!this.isTimeActive(overlay, now)) {
74
+ logger.debug(`Overlay ${overlay.file} not in time window`);
75
+ continue;
76
+ }
77
+
78
+ // Check geo-awareness
79
+ if (overlay.isGeoAware && overlay.geoLocation) {
80
+ if (this.scheduleManager && !this.scheduleManager.isWithinGeoFence(overlay.geoLocation)) {
81
+ logger.debug(`Overlay ${overlay.file} filtered by geofence`);
82
+ continue;
83
+ }
84
+ }
85
+
86
+ // Check criteria conditions
87
+ if (overlay.criteria && overlay.criteria.length > 0) {
88
+ if (!evaluateCriteria(overlay.criteria, { now, displayProperties: this.displayProperties })) {
89
+ logger.debug(`Overlay ${overlay.file} filtered by criteria`);
90
+ continue;
91
+ }
92
+ }
93
+
94
+ activeOverlays.push(overlay);
95
+ }
96
+
97
+ // Sort by priority (highest first)
98
+ activeOverlays.sort((a, b) => {
99
+ const priorityA = a.priority || 0;
100
+ const priorityB = b.priority || 0;
101
+ return priorityB - priorityA;
102
+ });
103
+
104
+ if (activeOverlays.length > 0) {
105
+ logger.info(`Active overlays: ${activeOverlays.length}`);
106
+ }
107
+
108
+ return activeOverlays;
109
+ }
110
+
111
+ /**
112
+ * Check if overlay is within its time window
113
+ * @param {Object} overlay - Overlay object
114
+ * @param {Date} now - Current time
115
+ * @returns {boolean}
116
+ */
117
+ isTimeActive(overlay, now) {
118
+ const from = overlay.fromDt ? new Date(overlay.fromDt) : null;
119
+ const to = overlay.toDt ? new Date(overlay.toDt) : null;
120
+
121
+ // Check time bounds
122
+ if (from && now < from) {
123
+ return false;
124
+ }
125
+ if (to && now > to) {
126
+ return false;
127
+ }
128
+
129
+ return true;
130
+ }
131
+
132
+ /**
133
+ * Check if overlay schedule needs update (every minute)
134
+ * @param {number} lastCheck - Last check timestamp
135
+ * @returns {boolean}
136
+ */
137
+ shouldCheckOverlays(lastCheck) {
138
+ if (!lastCheck) return true;
139
+ const elapsed = Date.now() - lastCheck;
140
+ return elapsed >= 60000; // 1 minute
141
+ }
142
+
143
+ /**
144
+ * Get overlay by file ID
145
+ * @param {number} fileId - Layout file ID
146
+ * @returns {Object|null}
147
+ */
148
+ getOverlayByFile(fileId) {
149
+ return this.overlays.find(o => o.file === fileId) || null;
150
+ }
151
+
152
+ /**
153
+ * Clear all overlays
154
+ */
155
+ clear() {
156
+ this.overlays = [];
157
+ logger.debug('Cleared all overlays');
158
+ }
159
+
160
+ /**
161
+ * Process overlay layouts (compatibility method for interrupt scheduler pattern)
162
+ * @param {Array} layouts - Base layouts
163
+ * @param {Array} overlays - Overlay layouts
164
+ * @returns {Array} Layouts (unchanged, overlays are separate)
165
+ */
166
+ processOverlays(layouts, overlays) {
167
+ // Overlays don't modify the main layout loop
168
+ // They are rendered separately on top
169
+ this.setOverlays(overlays);
170
+ return layouts;
171
+ }
172
+ }
173
+
174
+ export const overlayScheduler = new OverlayScheduler();