@xiboplayer/schedule 0.3.6 → 0.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiboplayer/schedule",
3
- "version": "0.3.6",
3
+ "version": "0.4.0",
4
4
  "description": "Complete scheduling solution: campaigns, dayparting, interrupts, and overlays",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -11,7 +11,7 @@
11
11
  "./overlays": "./src/overlays.js"
12
12
  },
13
13
  "dependencies": {
14
- "@xiboplayer/utils": "0.3.6"
14
+ "@xiboplayer/utils": "0.4.0"
15
15
  },
16
16
  "devDependencies": {
17
17
  "vitest": "^2.0.0"
package/src/criteria.js CHANGED
@@ -12,6 +12,13 @@
12
12
  * - hour: Hour (0-23)
13
13
  * - isoDay: ISO day of week (1=Monday, 7=Sunday)
14
14
  *
15
+ * Weather metrics (require weatherData in options):
16
+ * - weatherTemp: Current temperature
17
+ * - weatherHumidity: Current humidity percentage
18
+ * - weatherWindSpeed: Current wind speed
19
+ * - weatherCondition: Current weather condition (e.g. "Clear", "Rain")
20
+ * - weatherCloudCover: Cloud cover percentage
21
+ *
15
22
  * Supported conditions:
16
23
  * - equals, notEquals
17
24
  * - greaterThan, greaterThanOrEquals, lessThan, lessThanOrEquals
@@ -28,14 +35,26 @@ const log = createLogger('schedule:criteria');
28
35
 
29
36
  const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
30
37
 
38
+ /**
39
+ * Weather metric name → weatherData property mapping
40
+ */
41
+ const WEATHER_METRICS = {
42
+ weatherTemp: 'temperature',
43
+ weatherHumidity: 'humidity',
44
+ weatherWindSpeed: 'windSpeed',
45
+ weatherCondition: 'condition',
46
+ weatherCloudCover: 'cloudCover',
47
+ };
48
+
31
49
  /**
32
50
  * Get built-in metric value from current date/time
33
51
  * @param {string} metric - Metric name
34
52
  * @param {Date} now - Current date
35
53
  * @param {Object} displayProperties - Display property map from CMS
54
+ * @param {Object} weatherData - Weather data from GetWeather XMDS call
36
55
  * @returns {string|null} Metric value or null if unknown
37
56
  */
38
- function getMetricValue(metric, now, displayProperties = {}) {
57
+ function getMetricValue(metric, now, displayProperties = {}, weatherData = {}) {
39
58
  switch (metric) {
40
59
  case 'dayOfWeek':
41
60
  return DAY_NAMES[now.getDay()];
@@ -48,6 +67,15 @@ function getMetricValue(metric, now, displayProperties = {}) {
48
67
  case 'isoDay':
49
68
  return String(now.getDay() === 0 ? 7 : now.getDay());
50
69
  default:
70
+ // Check weather metrics
71
+ if (WEATHER_METRICS[metric]) {
72
+ const weatherKey = WEATHER_METRICS[metric];
73
+ if (weatherData[weatherKey] !== undefined) {
74
+ return String(weatherData[weatherKey]);
75
+ }
76
+ log.debug(`Weather metric "${metric}" requested but no weather data available`);
77
+ return null;
78
+ }
51
79
  // Check display properties (custom fields set in CMS)
52
80
  if (displayProperties[metric] !== undefined) {
53
81
  return String(displayProperties[metric]);
@@ -113,6 +141,7 @@ function evaluateCondition(actual, condition, expected, type) {
113
141
  * @param {Object} options
114
142
  * @param {Date} [options.now] - Current date (defaults to new Date())
115
143
  * @param {Object} [options.displayProperties] - Display property map from CMS
144
+ * @param {Object} [options.weatherData] - Weather data from GetWeather XMDS call
116
145
  * @returns {boolean} True if all criteria match (or no criteria)
117
146
  */
118
147
  export function evaluateCriteria(criteria, options = {}) {
@@ -120,9 +149,10 @@ export function evaluateCriteria(criteria, options = {}) {
120
149
 
121
150
  const now = options.now || new Date();
122
151
  const displayProperties = options.displayProperties || {};
152
+ const weatherData = options.weatherData || {};
123
153
 
124
154
  for (const criterion of criteria) {
125
- const actual = getMetricValue(criterion.metric, now, displayProperties);
155
+ const actual = getMetricValue(criterion.metric, now, displayProperties, weatherData);
126
156
  const matches = evaluateCondition(actual, criterion.condition, criterion.value, criterion.type);
127
157
 
128
158
  if (!matches) {
package/src/overlays.js CHANGED
@@ -115,8 +115,8 @@ export class OverlayScheduler {
115
115
  * @returns {boolean}
116
116
  */
117
117
  isTimeActive(overlay, now) {
118
- const from = overlay.fromDt ? new Date(overlay.fromDt) : null;
119
- const to = overlay.toDt ? new Date(overlay.toDt) : null;
118
+ const from = (overlay.fromdt || overlay.fromDt) ? new Date(overlay.fromdt || overlay.fromDt) : null;
119
+ const to = (overlay.todt || overlay.toDt) ? new Date(overlay.todt || overlay.toDt) : null;
120
120
 
121
121
  // Check time bounds
122
122
  if (from && now < from) {
@@ -387,4 +387,248 @@ describe('ScheduleManager - Dayparting', () => {
387
387
  expect(layouts[0]).toBe('999');
388
388
  });
389
389
  });
390
+
391
+ describe('Daily Recurrence', () => {
392
+ it('should activate daily schedule during time window', () => {
393
+ manager.setSchedule({
394
+ default: '0',
395
+ layouts: [
396
+ {
397
+ file: '800',
398
+ priority: 10,
399
+ fromdt: timeStr(9, 0),
400
+ todt: timeStr(17, 0),
401
+ recurrenceType: 'Day',
402
+ }
403
+ ],
404
+ campaigns: []
405
+ });
406
+
407
+ const noon = new Date();
408
+ noon.setHours(12, 0, 0, 0);
409
+ mockTimeAt(noon);
410
+
411
+ const layouts = manager.getCurrentLayouts();
412
+ expect(layouts).toHaveLength(1);
413
+ expect(layouts[0]).toBe('800');
414
+ });
415
+
416
+ it('should not activate daily schedule outside time window', () => {
417
+ manager.setSchedule({
418
+ default: '999',
419
+ layouts: [
420
+ {
421
+ file: '800',
422
+ priority: 10,
423
+ fromdt: timeStr(9, 0),
424
+ todt: timeStr(17, 0),
425
+ recurrenceType: 'Day',
426
+ }
427
+ ],
428
+ campaigns: []
429
+ });
430
+
431
+ const earlyMorning = new Date();
432
+ earlyMorning.setHours(7, 0, 0, 0);
433
+ mockTimeAt(earlyMorning);
434
+
435
+ const layouts = manager.getCurrentLayouts();
436
+ expect(layouts).toHaveLength(1);
437
+ expect(layouts[0]).toBe('999');
438
+ });
439
+
440
+ it('should respect recurrenceDetail interval (every N days)', () => {
441
+ // Create a schedule that started 2 days ago with interval=2
442
+ const startDate = new Date();
443
+ startDate.setDate(startDate.getDate() - 2);
444
+ startDate.setHours(9, 0, 0, 0);
445
+
446
+ const endDate = new Date();
447
+ endDate.setDate(endDate.getDate() + 30);
448
+ endDate.setHours(17, 0, 0, 0);
449
+
450
+ manager.setSchedule({
451
+ default: '999',
452
+ layouts: [
453
+ {
454
+ file: '801',
455
+ priority: 10,
456
+ fromdt: startDate.toISOString(),
457
+ todt: endDate.toISOString(),
458
+ recurrenceType: 'Day',
459
+ recurrenceDetail: 2,
460
+ }
461
+ ],
462
+ campaigns: []
463
+ });
464
+
465
+ // 2 days later = day 2, 2 % 2 = 0, should be active
466
+ const noon = new Date();
467
+ noon.setHours(12, 0, 0, 0);
468
+ mockTimeAt(noon);
469
+
470
+ const layouts = manager.getCurrentLayouts();
471
+ expect(layouts).toHaveLength(1);
472
+ expect(layouts[0]).toBe('801');
473
+ });
474
+
475
+ it('should skip days not matching interval', () => {
476
+ // Create a schedule that started 3 days ago with interval=2
477
+ const startDate = new Date();
478
+ startDate.setDate(startDate.getDate() - 3);
479
+ startDate.setHours(9, 0, 0, 0);
480
+
481
+ const endDate = new Date();
482
+ endDate.setDate(endDate.getDate() + 30);
483
+ endDate.setHours(17, 0, 0, 0);
484
+
485
+ manager.setSchedule({
486
+ default: '999',
487
+ layouts: [
488
+ {
489
+ file: '802',
490
+ priority: 10,
491
+ fromdt: startDate.toISOString(),
492
+ todt: endDate.toISOString(),
493
+ recurrenceType: 'Day',
494
+ recurrenceDetail: 2,
495
+ }
496
+ ],
497
+ campaigns: []
498
+ });
499
+
500
+ // 3 days later = day 3, 3 % 2 = 1, should NOT be active
501
+ const noon = new Date();
502
+ noon.setHours(12, 0, 0, 0);
503
+ mockTimeAt(noon);
504
+
505
+ const layouts = manager.getCurrentLayouts();
506
+ expect(layouts).toHaveLength(1);
507
+ expect(layouts[0]).toBe('999');
508
+ });
509
+ });
510
+
511
+ describe('Monthly Recurrence', () => {
512
+ it('should activate on matching day of month', () => {
513
+ const today = new Date();
514
+ const dayOfMonth = today.getDate();
515
+
516
+ manager.setSchedule({
517
+ default: '0',
518
+ layouts: [
519
+ {
520
+ file: '900',
521
+ priority: 10,
522
+ fromdt: timeStr(9, 0),
523
+ todt: timeStr(17, 0),
524
+ recurrenceType: 'Month',
525
+ recurrenceRepeatsOn: dayOfMonth.toString(),
526
+ }
527
+ ],
528
+ campaigns: []
529
+ });
530
+
531
+ const noon = new Date();
532
+ noon.setHours(12, 0, 0, 0);
533
+ mockTimeAt(noon);
534
+
535
+ const layouts = manager.getCurrentLayouts();
536
+ expect(layouts).toHaveLength(1);
537
+ expect(layouts[0]).toBe('900');
538
+ });
539
+
540
+ it('should not activate on wrong day of month', () => {
541
+ const today = new Date();
542
+ // Pick a day that isn't today (and is valid 1-28)
543
+ const otherDay = today.getDate() === 15 ? 16 : 15;
544
+
545
+ manager.setSchedule({
546
+ default: '999',
547
+ layouts: [
548
+ {
549
+ file: '901',
550
+ priority: 10,
551
+ fromdt: timeStr(9, 0),
552
+ todt: timeStr(17, 0),
553
+ recurrenceType: 'Month',
554
+ recurrenceRepeatsOn: otherDay.toString(),
555
+ }
556
+ ],
557
+ campaigns: []
558
+ });
559
+
560
+ const noon = new Date();
561
+ noon.setHours(12, 0, 0, 0);
562
+ mockTimeAt(noon);
563
+
564
+ const layouts = manager.getCurrentLayouts();
565
+ expect(layouts).toHaveLength(1);
566
+ expect(layouts[0]).toBe('999');
567
+ });
568
+
569
+ it('should support multiple days of month', () => {
570
+ const today = new Date();
571
+ const dayOfMonth = today.getDate();
572
+
573
+ manager.setSchedule({
574
+ default: '0',
575
+ layouts: [
576
+ {
577
+ file: '902',
578
+ priority: 10,
579
+ fromdt: timeStr(9, 0),
580
+ todt: timeStr(17, 0),
581
+ recurrenceType: 'Month',
582
+ recurrenceRepeatsOn: `1,${dayOfMonth},28`,
583
+ }
584
+ ],
585
+ campaigns: []
586
+ });
587
+
588
+ const noon = new Date();
589
+ noon.setHours(12, 0, 0, 0);
590
+ mockTimeAt(noon);
591
+
592
+ const layouts = manager.getCurrentLayouts();
593
+ expect(layouts).toHaveLength(1);
594
+ expect(layouts[0]).toBe('902');
595
+ });
596
+
597
+ it('should respect recurrenceDetail interval (every N months)', () => {
598
+ // Start date 2 months ago, interval=2 → should be active
599
+ const startDate = new Date();
600
+ startDate.setMonth(startDate.getMonth() - 2);
601
+ startDate.setHours(9, 0, 0, 0);
602
+
603
+ const endDate = new Date();
604
+ endDate.setFullYear(endDate.getFullYear() + 1);
605
+ endDate.setHours(17, 0, 0, 0);
606
+
607
+ const dayOfMonth = new Date().getDate();
608
+
609
+ manager.setSchedule({
610
+ default: '999',
611
+ layouts: [
612
+ {
613
+ file: '903',
614
+ priority: 10,
615
+ fromdt: startDate.toISOString(),
616
+ todt: endDate.toISOString(),
617
+ recurrenceType: 'Month',
618
+ recurrenceDetail: 2,
619
+ recurrenceRepeatsOn: dayOfMonth.toString(),
620
+ }
621
+ ],
622
+ campaigns: []
623
+ });
624
+
625
+ const noon = new Date();
626
+ noon.setHours(12, 0, 0, 0);
627
+ mockTimeAt(noon);
628
+
629
+ const layouts = manager.getCurrentLayouts();
630
+ expect(layouts).toHaveLength(1);
631
+ expect(layouts[0]).toBe('903');
632
+ });
633
+ });
390
634
  });
package/src/schedule.js CHANGED
@@ -13,6 +13,7 @@ export class ScheduleManager {
13
13
  this.playHistory = new Map(); // Track plays per layout: layoutId -> [timestamps]
14
14
  this.interruptScheduler = options.interruptScheduler || null; // Optional interrupt scheduler
15
15
  this.displayProperties = options.displayProperties || {}; // CMS display custom properties
16
+ this.weatherData = {}; // Weather data from GetWeather XMDS call
16
17
  this.playerLocation = null; // { latitude, longitude } from Geolocation API
17
18
  this._layoutMetadata = new Map(); // layoutFile → { syncEvent, shareOfVoice, ... }
18
19
  }
@@ -24,6 +25,14 @@ export class ScheduleManager {
24
25
  this.schedule = schedule;
25
26
  }
26
27
 
28
+ /**
29
+ * Update weather data for criteria evaluation
30
+ * @param {Object} data - Parsed weather object { temperature, humidity, windSpeed, condition, cloudCover }
31
+ */
32
+ setWeatherData(data) {
33
+ this.weatherData = data || {};
34
+ }
35
+
27
36
  /**
28
37
  * Get data connectors from current schedule
29
38
  * @returns {Array} Data connector configurations, or empty array
@@ -33,8 +42,8 @@ export class ScheduleManager {
33
42
  }
34
43
 
35
44
  /**
36
- * Check if a schedule item is active based on recurrence rules
37
- * Supports weekly dayparting (recurring schedules on specific days/times)
45
+ * Check if a schedule item is active based on recurrence rules.
46
+ * Supports Week, Day, and Month recurrence types.
38
47
  */
39
48
  isRecurringScheduleActive(item, now) {
40
49
  // If no recurrence, it's not a recurring schedule
@@ -42,23 +51,7 @@ export class ScheduleManager {
42
51
  return true; // Not a recurring schedule, use date/time checks instead
43
52
  }
44
53
 
45
- // Currently only support Weekly recurrence (dayparting)
46
- if (item.recurrenceType !== 'Week') {
47
- return true; // Unsupported recurrence type, fallback to date/time checks
48
- }
49
-
50
- // Check if current day of week matches recurrenceRepeatsOn
51
- // recurrenceRepeatsOn format: "1,2,3,4,5" (1=Monday, 7=Sunday, ISO format)
52
- if (item.recurrenceRepeatsOn) {
53
- const currentDayOfWeek = this.getIsoDayOfWeek(now);
54
- const allowedDays = item.recurrenceRepeatsOn.split(',').map(d => parseInt(d.trim()));
55
-
56
- if (!allowedDays.includes(currentDayOfWeek)) {
57
- return false; // Today is not in the allowed days
58
- }
59
- }
60
-
61
- // Check recurrence range if specified
54
+ // Check recurrence range first (applies to all types)
62
55
  if (item.recurrenceRange) {
63
56
  const rangeEnd = new Date(item.recurrenceRange);
64
57
  if (now > rangeEnd) {
@@ -66,7 +59,61 @@ export class ScheduleManager {
66
59
  }
67
60
  }
68
61
 
69
- return true;
62
+ switch (item.recurrenceType) {
63
+ case 'Week': {
64
+ // Check if current day of week matches recurrenceRepeatsOn
65
+ // recurrenceRepeatsOn format: "1,2,3,4,5" (1=Monday, 7=Sunday, ISO format)
66
+ if (item.recurrenceRepeatsOn) {
67
+ const currentDayOfWeek = this.getIsoDayOfWeek(now);
68
+ const allowedDays = item.recurrenceRepeatsOn.split(',').map(d => parseInt(d.trim()));
69
+ if (!allowedDays.includes(currentDayOfWeek)) {
70
+ return false;
71
+ }
72
+ }
73
+ return true;
74
+ }
75
+
76
+ case 'Day': {
77
+ // Daily recurrence with optional interval (recurrenceDetail)
78
+ // If recurrenceDetail > 1, only active every N days from fromdt
79
+ const interval = item.recurrenceDetail || 1;
80
+ if (interval > 1 && item.fromdt) {
81
+ const startDate = new Date(item.fromdt);
82
+ const diffMs = now.getTime() - startDate.getTime();
83
+ const diffDays = Math.floor(diffMs / 86400000);
84
+ if (diffDays < 0 || diffDays % interval !== 0) {
85
+ return false;
86
+ }
87
+ }
88
+ return true;
89
+ }
90
+
91
+ case 'Month': {
92
+ // Monthly recurrence — recurrenceRepeatsOn is day-of-month (1-31)
93
+ if (item.recurrenceRepeatsOn) {
94
+ const allowedDays = item.recurrenceRepeatsOn.split(',').map(d => parseInt(d.trim()));
95
+ const currentDayOfMonth = now.getDate();
96
+ if (!allowedDays.includes(currentDayOfMonth)) {
97
+ return false;
98
+ }
99
+ }
100
+ // If recurrenceDetail > 1, only active every N months from fromdt
101
+ const interval = item.recurrenceDetail || 1;
102
+ if (interval > 1 && item.fromdt) {
103
+ const startDate = new Date(item.fromdt);
104
+ const monthsDiff = (now.getFullYear() - startDate.getFullYear()) * 12
105
+ + now.getMonth() - startDate.getMonth();
106
+ if (monthsDiff < 0 || monthsDiff % interval !== 0) {
107
+ return false;
108
+ }
109
+ }
110
+ return true;
111
+ }
112
+
113
+ default:
114
+ log.debug(`Unsupported recurrence type: ${item.recurrenceType}`);
115
+ return true; // Unknown type, fallback to date/time checks
116
+ }
70
117
  }
71
118
 
72
119
  /**
@@ -86,7 +133,7 @@ export class ScheduleManager {
86
133
  const to = item.todt ? new Date(item.todt) : null;
87
134
 
88
135
  // For recurring schedules, check time-of-day instead of full datetime
89
- if (item.recurrenceType === 'Week') {
136
+ if (item.recurrenceType === 'Week' || item.recurrenceType === 'Day' || item.recurrenceType === 'Month') {
90
137
  // Extract time from fromdt/todt and compare with current time
91
138
  if (from && to) {
92
139
  const currentTime = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds();
@@ -169,7 +216,7 @@ export class ScheduleManager {
169
216
  if (!this.isRecurringScheduleActive(layout, now)) continue;
170
217
  if (!this.isTimeActive(layout, now)) continue;
171
218
  if (layout.criteria && layout.criteria.length > 0) {
172
- if (!evaluateCriteria(layout.criteria, { now, displayProperties: this.displayProperties })) continue;
219
+ if (!evaluateCriteria(layout.criteria, { now, displayProperties: this.displayProperties, weatherData: this.weatherData })) continue;
173
220
  }
174
221
  if (layout.isGeoAware && layout.geoLocation) {
175
222
  if (!this.isWithinGeoFence(layout.geoLocation)) continue;
@@ -200,6 +247,80 @@ export class ScheduleManager {
200
247
  return results;
201
248
  }
202
249
 
250
+ /**
251
+ * Detect schedule conflicts: time windows where multiple layouts compete
252
+ * and lower-priority ones are hidden.
253
+ *
254
+ * Scans the schedule in 1-minute increments over the given window.
255
+ * At each point, collects all time-active layouts (after criteria/geofence
256
+ * filtering but before priority filtering). If multiple priorities exist,
257
+ * the lower-priority entries are reported as hidden.
258
+ *
259
+ * @param {Object} [options]
260
+ * @param {Date} [options.from] - Start time (default: now)
261
+ * @param {number} [options.hours] - Hours to scan (default: 24)
262
+ * @returns {Array<{startTime: Date, endTime: Date, winner: {file: string, priority: number}, hidden: Array<{file: string, priority: number}>}>}
263
+ */
264
+ detectConflicts(options = {}) {
265
+ const from = options.from || new Date();
266
+ const hours = options.hours || 24;
267
+ const to = new Date(from.getTime() + hours * 3600000);
268
+ const stepMs = 60000; // 1-minute granularity
269
+ const conflicts = [];
270
+ let current = null; // Current conflict window being built
271
+
272
+ for (let t = from.getTime(); t < to.getTime(); t += stepMs) {
273
+ const time = new Date(t);
274
+ const allLayouts = this.getAllLayoutsAtTime(time);
275
+
276
+ if (allLayouts.length === 0) {
277
+ // No layouts → close any open conflict
278
+ if (current) { conflicts.push(current); current = null; }
279
+ continue;
280
+ }
281
+
282
+ const maxPriority = Math.max(...allLayouts.map(l => l.priority));
283
+ const hidden = allLayouts.filter(l => l.priority < maxPriority);
284
+
285
+ if (hidden.length === 0) {
286
+ // No conflict at this time
287
+ if (current) { conflicts.push(current); current = null; }
288
+ continue;
289
+ }
290
+
291
+ // Conflict exists — build or extend window
292
+ const winners = allLayouts.filter(l => l.priority === maxPriority);
293
+ const winnerKey = winners.map(w => w.file).sort().join(',');
294
+ const hiddenKey = hidden.map(h => `${h.file}:${h.priority}`).sort().join(',');
295
+
296
+ if (current && current._winnerKey === winnerKey && current._hiddenKey === hiddenKey) {
297
+ // Same conflict continues — extend window
298
+ current.endTime = new Date(t + stepMs);
299
+ } else {
300
+ // New or changed conflict
301
+ if (current) conflicts.push(current);
302
+ current = {
303
+ startTime: new Date(t),
304
+ endTime: new Date(t + stepMs),
305
+ winner: { file: winners[0].file, priority: maxPriority },
306
+ hidden: hidden.map(h => ({ file: h.file, priority: h.priority })),
307
+ _winnerKey: winnerKey,
308
+ _hiddenKey: hiddenKey,
309
+ };
310
+ }
311
+ }
312
+
313
+ if (current) conflicts.push(current);
314
+
315
+ // Clean internal keys
316
+ for (const c of conflicts) {
317
+ delete c._winnerKey;
318
+ delete c._hiddenKey;
319
+ }
320
+
321
+ return conflicts;
322
+ }
323
+
203
324
  /**
204
325
  * Internal: evaluate schedule at a given time.
205
326
  * @param {Date} now - Time to evaluate
@@ -258,7 +379,7 @@ export class ScheduleManager {
258
379
 
259
380
  // Check criteria conditions (date/time, display properties)
260
381
  if (layout.criteria && layout.criteria.length > 0) {
261
- if (!evaluateCriteria(layout.criteria, { now, displayProperties: this.displayProperties })) {
382
+ if (!evaluateCriteria(layout.criteria, { now, displayProperties: this.displayProperties, weatherData: this.weatherData })) {
262
383
  _log('[Schedule] Layout', layout.id, 'filtered by criteria');
263
384
  continue;
264
385
  }
@@ -454,6 +575,45 @@ export class ScheduleManager {
454
575
  return this._layoutMetadata.get(layoutFile) || null;
455
576
  }
456
577
 
578
+ /**
579
+ * Get current layouts with the default layout interleaved between scheduled layouts.
580
+ *
581
+ * Xibo CMS expects the default layout (fallback) to play between each scheduled
582
+ * layout in the rotation. For example, with layouts [A, B, C] and default D,
583
+ * the result is [A, D, B, D, C, D].
584
+ *
585
+ * No interleaving when:
586
+ * - There is no default layout configured
587
+ * - There are no scheduled layouts (only default plays)
588
+ * - There is only one scheduled layout (no gaps to fill)
589
+ *
590
+ * @returns {string[]} Layout files with default interleaved
591
+ */
592
+ getInterleavedLayouts() {
593
+ const layouts = this.getCurrentLayouts();
594
+ const defaultLayout = this.schedule?.default;
595
+
596
+ // No interleaving needed: no default, no layouts, or single layout
597
+ if (!defaultLayout || layouts.length <= 1) {
598
+ return layouts;
599
+ }
600
+
601
+ // If the only layout is the default itself, return as-is
602
+ if (layouts.length === 1 && layouts[0] === defaultLayout) {
603
+ return layouts;
604
+ }
605
+
606
+ // Interleave: A, D, B, D, C, D
607
+ const interleaved = [];
608
+ for (const layout of layouts) {
609
+ interleaved.push(layout);
610
+ interleaved.push(defaultLayout);
611
+ }
612
+
613
+ log.info('[Schedule] Interleaved', layouts.length, 'layouts with default', defaultLayout);
614
+ return interleaved;
615
+ }
616
+
457
617
  /**
458
618
  * Check if any current layouts are sync events
459
619
  * @returns {boolean}
@@ -196,6 +196,132 @@ describe('ScheduleManager - Campaigns', () => {
196
196
  });
197
197
  });
198
198
 
199
+ describe('ScheduleManager - Default Layout Interleaving', () => {
200
+ let manager;
201
+
202
+ beforeEach(() => {
203
+ manager = new ScheduleManager();
204
+ });
205
+
206
+ it('should interleave default layout between multiple scheduled layouts', () => {
207
+ manager.setSchedule({
208
+ default: '999',
209
+ layouts: [
210
+ { file: '100', priority: 10, fromdt: dateStr(-1), todt: dateStr(1) },
211
+ { file: '200', priority: 10, fromdt: dateStr(-1), todt: dateStr(1) },
212
+ { file: '300', priority: 10, fromdt: dateStr(-1), todt: dateStr(1) }
213
+ ],
214
+ campaigns: []
215
+ });
216
+
217
+ const layouts = manager.getInterleavedLayouts();
218
+
219
+ expect(layouts).toEqual(['100', '999', '200', '999', '300', '999']);
220
+ });
221
+
222
+ it('should not interleave when only default layout is active', () => {
223
+ manager.setSchedule({
224
+ default: '999',
225
+ layouts: [],
226
+ campaigns: []
227
+ });
228
+
229
+ const layouts = manager.getInterleavedLayouts();
230
+
231
+ expect(layouts).toEqual(['999']);
232
+ });
233
+
234
+ it('should not interleave when only one scheduled layout', () => {
235
+ manager.setSchedule({
236
+ default: '999',
237
+ layouts: [
238
+ { file: '100', priority: 10, fromdt: dateStr(-1), todt: dateStr(1) }
239
+ ],
240
+ campaigns: []
241
+ });
242
+
243
+ const layouts = manager.getInterleavedLayouts();
244
+
245
+ expect(layouts).toEqual(['100']);
246
+ });
247
+
248
+ it('should preserve layout order when interleaving', () => {
249
+ manager.setSchedule({
250
+ default: '999',
251
+ layouts: [],
252
+ campaigns: [
253
+ {
254
+ id: '1',
255
+ priority: 10,
256
+ fromdt: dateStr(-1),
257
+ todt: dateStr(1),
258
+ layouts: [
259
+ { file: '205' },
260
+ { file: '203' },
261
+ { file: '204' }
262
+ ]
263
+ }
264
+ ]
265
+ });
266
+
267
+ const layouts = manager.getInterleavedLayouts();
268
+
269
+ // Order must be preserved: 205, default, 203, default, 204, default
270
+ expect(layouts).toEqual(['205', '999', '203', '999', '204', '999']);
271
+ });
272
+
273
+ it('should return empty array when no schedule is set', () => {
274
+ const layouts = manager.getInterleavedLayouts();
275
+
276
+ expect(layouts).toEqual([]);
277
+ });
278
+
279
+ it('should not interleave when no default layout is configured', () => {
280
+ manager.setSchedule({
281
+ layouts: [
282
+ { file: '100', priority: 10, fromdt: dateStr(-1), todt: dateStr(1) },
283
+ { file: '200', priority: 10, fromdt: dateStr(-1), todt: dateStr(1) }
284
+ ],
285
+ campaigns: []
286
+ });
287
+
288
+ const layouts = manager.getInterleavedLayouts();
289
+
290
+ // No default, so no interleaving — just the scheduled layouts
291
+ expect(layouts).toEqual(['100', '200']);
292
+ });
293
+
294
+ it('should interleave with campaign and standalone layouts at same priority', () => {
295
+ manager.setSchedule({
296
+ default: '999',
297
+ layouts: [
298
+ { file: '100', priority: 10, fromdt: dateStr(-1), todt: dateStr(1) }
299
+ ],
300
+ campaigns: [
301
+ {
302
+ id: '1',
303
+ priority: 10,
304
+ fromdt: dateStr(-1),
305
+ todt: dateStr(1),
306
+ layouts: [
307
+ { file: '200' },
308
+ { file: '201' }
309
+ ]
310
+ }
311
+ ]
312
+ });
313
+
314
+ const layouts = manager.getInterleavedLayouts();
315
+
316
+ // 3 scheduled layouts + 3 default insertions = 6
317
+ expect(layouts).toHaveLength(6);
318
+ // Every odd-indexed element should be the default
319
+ expect(layouts[1]).toBe('999');
320
+ expect(layouts[3]).toBe('999');
321
+ expect(layouts[5]).toBe('999');
322
+ });
323
+ });
324
+
199
325
  describe('ScheduleManager - Actions and Commands', () => {
200
326
  let manager;
201
327
 
@@ -502,4 +628,83 @@ describe('ScheduleManager - Actions and Commands', () => {
502
628
  expect(commands).toEqual([]);
503
629
  });
504
630
  });
631
+
632
+ describe('Conflict Detection', () => {
633
+ it('should detect no conflicts when all layouts share the same priority', () => {
634
+ manager.setSchedule({
635
+ default: '0',
636
+ layouts: [
637
+ { file: '100', priority: 5, fromdt: dateStr(-1), todt: dateStr(1) },
638
+ { file: '101', priority: 5, fromdt: dateStr(-1), todt: dateStr(1) },
639
+ ],
640
+ campaigns: [],
641
+ });
642
+
643
+ const conflicts = manager.detectConflicts({ hours: 2 });
644
+ expect(conflicts).toEqual([]);
645
+ });
646
+
647
+ it('should detect conflict when higher-priority layout hides lower-priority', () => {
648
+ manager.setSchedule({
649
+ default: '0',
650
+ layouts: [
651
+ { file: '100', priority: 5, fromdt: dateStr(-1), todt: dateStr(1) },
652
+ { file: '101', priority: 10, fromdt: dateStr(-1), todt: dateStr(1) },
653
+ ],
654
+ campaigns: [],
655
+ });
656
+
657
+ const conflicts = manager.detectConflicts({ hours: 2 });
658
+ expect(conflicts.length).toBeGreaterThan(0);
659
+ expect(conflicts[0].winner.file).toBe('101');
660
+ expect(conflicts[0].winner.priority).toBe(10);
661
+ expect(conflicts[0].hidden).toEqual([{ file: '100', priority: 5 }]);
662
+ });
663
+
664
+ it('should detect conflict between campaign and standalone layout', () => {
665
+ manager.setSchedule({
666
+ default: '0',
667
+ layouts: [
668
+ { file: '100', priority: 3, fromdt: dateStr(-1), todt: dateStr(1) },
669
+ ],
670
+ campaigns: [
671
+ {
672
+ id: '1',
673
+ priority: 8,
674
+ fromdt: dateStr(-1),
675
+ todt: dateStr(1),
676
+ layouts: [{ file: '200' }],
677
+ },
678
+ ],
679
+ });
680
+
681
+ const conflicts = manager.detectConflicts({ hours: 2 });
682
+ expect(conflicts.length).toBeGreaterThan(0);
683
+ expect(conflicts[0].winner.priority).toBe(8);
684
+ expect(conflicts[0].hidden[0].file).toBe('100');
685
+ });
686
+
687
+ it('should return empty array when no schedule is set', () => {
688
+ const conflicts = manager.detectConflicts();
689
+ expect(conflicts).toEqual([]);
690
+ });
691
+
692
+ it('should merge consecutive minutes into a single conflict window', () => {
693
+ manager.setSchedule({
694
+ default: '0',
695
+ layouts: [
696
+ { file: '100', priority: 5, fromdt: dateStr(-1), todt: dateStr(1) },
697
+ { file: '101', priority: 10, fromdt: dateStr(-1), todt: dateStr(1) },
698
+ ],
699
+ campaigns: [],
700
+ });
701
+
702
+ const conflicts = manager.detectConflicts({ hours: 2 });
703
+ // Should be 1 merged window, not 120 individual minutes
704
+ expect(conflicts.length).toBe(1);
705
+ const windowMs = conflicts[0].endTime.getTime() - conflicts[0].startTime.getTime();
706
+ // Window should span most of the 2-hour scan (overlap runs for ~2h)
707
+ expect(windowMs).toBeGreaterThanOrEqual(60 * 60 * 1000); // at least 1 hour
708
+ });
709
+ });
505
710
  });
package/src/timeline.js CHANGED
@@ -175,12 +175,20 @@ export function calculateTimeline(schedule, durations, options = {}) {
175
175
  const timeMs = currentTime.getTime();
176
176
  let playable;
177
177
 
178
+ let hiddenLayouts = null;
179
+
178
180
  if (hasFullApi) {
179
181
  // Full simulation: get ALL active layouts, apply rate limiting + priority
180
182
  const allLayouts = schedule.getAllLayoutsAtTime(currentTime);
181
183
  playable = allLayouts.length > 0
182
184
  ? getPlayableLayouts(allLayouts, simPlays, timeMs)
183
185
  : [];
186
+ // Detect hidden layouts (lower priority, not playing)
187
+ if (allLayouts.length > playable.length) {
188
+ hiddenLayouts = allLayouts
189
+ .filter(l => !playable.includes(l.file))
190
+ .map(l => ({ file: l.file, priority: l.priority }));
191
+ }
184
192
  } else {
185
193
  // Legacy fallback: no rate limiting simulation
186
194
  playable = schedule.getLayoutsAtTime(currentTime);
@@ -211,13 +219,17 @@ export function calculateTimeline(schedule, durations, options = {}) {
211
219
  const dur = durations.get(file) || defaultDuration;
212
220
  const endMs = currentTime.getTime() + dur * 1000;
213
221
 
214
- timeline.push({
222
+ const entry = {
215
223
  layoutFile: file,
216
224
  startTime: new Date(currentTime),
217
225
  endTime: new Date(endMs),
218
226
  duration: dur,
219
227
  isDefault: false,
220
- });
228
+ };
229
+ if (hiddenLayouts && hiddenLayouts.length > 0) {
230
+ entry.hidden = hiddenLayouts;
231
+ }
232
+ timeline.push(entry);
221
233
 
222
234
  // Record simulated play
223
235
  if (hasFullApi) {