@xiboplayer/schedule 0.3.7 → 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 +2 -2
- package/src/criteria.js +32 -2
- package/src/overlays.js +2 -2
- package/src/schedule.dayparting.test.js +244 -0
- package/src/schedule.js +183 -23
- package/src/schedule.test.js +205 -0
- package/src/timeline.js +14 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/schedule",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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}
|
package/src/schedule.test.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|