diff --git a/CHANGELOG.md b/CHANGELOG.md index 862111d5..8fbd295c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. ## Unreleased +- [#128](https://github.com/os2display/display-client/pull/128) + - Fixed rrule evaluation to handle local time correctly. + ## [2.0.3] - 2024-05-21 - [#125](https://github.com/os2display/display-client/pull/125) diff --git a/package.json b/package.json index 99b07993..83064e68 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "react-dom": "^18.2.0", "react-intl": "^5.20.2", "react-transition-group": "^4.4.2", - "rrule": "^2.6.8", + "rrule": "^2.7.2", "sass": "^1.37.5", "styled-components": "^5.3.1", "suncalc": "^1.9.0", diff --git a/src/schedule.js b/src/schedule.js new file mode 100644 index 00000000..dfef377a --- /dev/null +++ b/src/schedule.js @@ -0,0 +1,54 @@ +import { RRule } from 'rrule'; + +class ScheduleUtils { + static occursNow(rruleString, durationSeconds) { + const rrule = RRule.fromString(rruleString.replace("\\n", "\n")); + const duration = durationSeconds * 1000; + + const now = new Date(); + + // From the RRULE docs:_ "Returned "UTC" dates are always meant to be + // interpreted as dates in your local timezone. This may mean you have to + // do additional conversion to get the "correct" local time with offset + // applied." + // + // We do the opposite to ensure that datetime comparisons works as expected. + // For evaluation with the RRule library we pretend that "now" is in UTC instead of the local timezone. + // That is 9:00 in Europe/Copenhagen time will be evaluated as if it was 9:00 in UTC. + // @see https://github.com/jkbrzt/rrule#important-use-utc-dates + const nowWithoutTimezone = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), now.getMinutes(), now.getSeconds())); + + // Subtract duration from now to make sure all relevant occurrences are considered. + const from = new Date(nowWithoutTimezone.getTime() - duration); + + let occurs = false; + + // RRule.prototype.between(after, before, inc=false [, iterator]) + // The between() function expects "after" and "before" to be in pretend UTC as + // described above. + rrule.between( + from, + nowWithoutTimezone, + true, + function iterator(occurrenceDate) { + // The "ccurrenceDate" we are iterating over contains a "pretend UTC" datetime + // object. As above, if the time for "occurrenceDate" is 09:00 UTC it should be + // treated as 09:00 local time regardsless of the actual local timezone + const end = new Date(occurrenceDate.getTime() + duration); + + if (nowWithoutTimezone >= occurrenceDate && nowWithoutTimezone <= end) { + occurs = true; + // break iteration. + return false; + } + + // continue iteration. + return true; + } + ); + + return occurs; + } +} + +export default ScheduleUtils; diff --git a/src/service/scheduleService.js b/src/service/scheduleService.js index 2aed7643..bcbfbc9b 100644 --- a/src/service/scheduleService.js +++ b/src/service/scheduleService.js @@ -1,11 +1,12 @@ import cloneDeep from 'lodash.clonedeep'; import sha256 from 'crypto-js/sha256'; import Md5 from 'crypto-js/md5'; -import RRule from 'rrule'; +import { RRule, datetime } from 'rrule'; import Base64 from 'crypto-js/enc-base64'; import isPublished from '../util/isPublished'; import Logger from '../logger/logger'; import ConfigLoader from '../config-loader'; +import ScheduleUtils from "../schedule"; /** * ScheduleService. @@ -174,10 +175,6 @@ class ScheduleService { static findScheduledSlides(playlists, regionId) { const slides = []; - const now = new Date(); - const startOfDay = new Date(); - startOfDay.setUTCHours(0, 0, 0, 0); - playlists.forEach((playlist) => { const { schedules } = playlist; @@ -185,38 +182,29 @@ class ScheduleService { return; } - let occurs = true; + let active = true; // If schedules are set for the playlist, do not show playlist unless a schedule is active. if (schedules.length > 0) { - occurs = false; - - schedules.forEach((schedule) => { - const rrule = RRule.fromString(schedule.rrule.replace('\\n', '\n')); - rrule.between( - // Subtract duration from now to make sure all relevant occurrences are considered. - new Date( - now.getTime() - (schedule.duration ? schedule.duration * 1000 : 0) - ), - now, - true, - function iterator(occurrenceDate) { - const occurrenceEnd = new Date( - occurrenceDate.getTime() + schedule.duration * 1000 - ); - - if (now >= occurrenceDate && now <= occurrenceEnd) { - occurs = true; - // Break the iteration. - return false; - } - return true; - } - ); + active = false; + + // Run through all schedule item and see if it occurs now. If one or more occur now, the playlist is active. + schedules.every((schedule) => { + const scheduleOccurs = ScheduleUtils.occursNow(schedule.rrule, schedule.duration); + + if (scheduleOccurs) { + active = true; + + // Break iteration. + return false; + } + + // Continue iteration. + return true; }); } - if (occurs) { + if (active) { playlist?.slidesData?.forEach((slide) => { if (!isPublished(slide.published)) { return; @@ -229,6 +217,8 @@ class ScheduleService { newSlide.executionId = `EXE-ID-${executionId}`; slides.push(newSlide); }); + } else { + Logger.log('info', `Playlist ${playlist['@id']} not scheduled for now`); } }); diff --git a/yarn.lock b/yarn.lock index 580ceafa..c088c629 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7628,11 +7628,6 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" -luxon@^1.21.3: - version "1.28.0" - resolved "https://registry.yarnpkg.com/luxon/-/luxon-1.28.0.tgz#e7f96daad3938c06a62de0fb027115d251251fbf" - integrity sha512-TfTiyvZhwBYM/7QdAVDh+7dBTBA29v4ik0Ce9zda3Mnf8on1S5KJI8P2jKFZ8+5C0jhmr0KwJEO/Wdpm0VeWJQ== - lz-string@^1.4.4: version "1.4.4" resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26" @@ -10366,14 +10361,12 @@ rollup@^1.31.1: "@types/node" "*" acorn "^7.1.0" -rrule@^2.6.8: - version "2.6.8" - resolved "https://registry.yarnpkg.com/rrule/-/rrule-2.6.8.tgz#c61714f246e7676e8efa16c2baabac199f20f6db" - integrity sha512-cUaXuUPrz9d1wdyzHsBfT1hptKlGgABeCINFXFvulEPqh9Np9BnF3C3lrv9uO54IIr8VDb58tsSF3LhsW+4VRw== +rrule@^2.7.2: + version "2.8.1" + resolved "https://registry.yarnpkg.com/rrule/-/rrule-2.8.1.tgz#e8341a9ce3e68ce5b8da4d502e893cd9f286805e" + integrity sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw== dependencies: - tslib "^1.10.0" - optionalDependencies: - luxon "^1.21.3" + tslib "^2.4.0" rsvp@^4.8.4: version "4.8.5" @@ -11595,7 +11588,7 @@ tsconfig-paths@^3.9.0: minimist "^1.2.0" strip-bom "^3.0.0" -tslib@^1.10.0, tslib@^1.8.1: +tslib@^1.8.1: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== @@ -11605,6 +11598,11 @@ tslib@^2.0.3, tslib@^2.1.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e" integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg== +tslib@^2.4.0: + version "2.6.3" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" + integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== + tsutils@^3.17.1, tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"