V2 monorepo (#285)

* refactor(project structure): UI

* refactor(project structure): extract utilities

* refactor(project structure): remove unused

* refactor(project structure): electron

* refactor(project structure): server

refactor: migrate to vitest

refactor: monorepo config

* refactor: extract application menu

* refactor: exit process

* refactor: extract tray menu

* chore: electron build

* Added Seconds in studio clock #282
---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>

---------

Co-authored-by: Fabian Posenau <fabian.p99@gmx.de>
Co-authored-by: Fabian Posenau <fabian@fphome.de>
This commit is contained in:
Carlos Valente
2023-02-14 22:02:15 +01:00
committed by GitHub
parent 3918758d32
commit de9a7a87fd
439 changed files with 11381 additions and 14294 deletions
+205
View File
@@ -0,0 +1,205 @@
/**
* starts loaded timer
*/
import { socketProvider } from '../classes/socket/SocketController.js';
import { eventLoader, EventLoader } from '../classes/event-loader/EventLoader.js';
import { eventTimer, TimerService } from './TimerService.js';
/**
* Service manages playback status of app
* Coordinating with necessary services
*/
export class PlaybackService {
/**
* makes calls for loading and starting given event
* @param {object} event
* @return {boolean} success
*/
static loadEvent(event) {
let success = false;
if (!event) {
socketProvider.error('PLAYBACK', 'No event found');
} else if (event.skip) {
socketProvider.warning('PLAYBACK', `Refused playback of skipped event ID ${event.id}`);
} else {
eventLoader.loadEvent(event);
eventTimer.load(event);
success = true;
}
socketProvider.broadcastState();
return success;
}
/**
* starts event matching given ID
* @param {string} eventId
* @return {boolean} success
*/
static startById(eventId) {
const event = EventLoader.getEventWithId(eventId);
const success = PlaybackService.loadEvent(event);
if (success) {
socketProvider.info('PLAYBACK', `Loaded event with ID ${event.id}`);
PlaybackService.start();
}
return success;
}
/**
* starts an event at index
* @param {number} eventIndex
* @return {boolean} success
*/
static startByIndex(eventIndex) {
const event = EventLoader.getEventAtIndex(eventIndex);
const success = PlaybackService.loadEvent(event);
if (success) {
socketProvider.info('PLAYBACK', `Loaded event with ID ${event.id}`);
PlaybackService.start();
}
return success;
}
/**
* loads event matching given ID
* @param {string} eventId
* @return {boolean} success
*/
static loadById(eventId) {
const event = EventLoader.getEventWithId(eventId);
const success = PlaybackService.loadEvent(event);
if (success) {
socketProvider.info('PLAYBACK', `Loaded event with ID ${event.id}`);
}
return success;
}
/**
* loads event matching given ID
* @param {number} eventIndex
* @return {boolean} success
*/
static loadByIndex(eventIndex) {
const event = EventLoader.getEventAtIndex(eventIndex);
const success = PlaybackService.loadEvent(event);
if (success) {
socketProvider.info('PLAYBACK', `Loaded event with ID ${event.id}`);
}
return success;
}
/**
* Loads event before currently selected
*/
static loadPrevious() {
const previousEvent = eventLoader.findPrevious();
if (previousEvent) {
const success = PlaybackService.loadEvent(previousEvent);
if (success) {
socketProvider.info('PLAYBACK', `Loaded event with ID ${previousEvent.id}`);
}
}
}
/**
* Loads event after currently selected
*/
static loadNext() {
const nextEvent = eventLoader.findNext();
if (nextEvent) {
const success = PlaybackService.loadEvent(nextEvent);
if (success) {
socketProvider.info('PLAYBACK', `Loaded event with ID ${nextEvent.id}`);
}
}
}
/**
* Starts playback on selected event
*/
static start() {
if (eventLoader.selectedEventId) {
eventTimer.start();
const newState = eventTimer.playback;
socketProvider.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
}
socketProvider.broadcastState();
}
/**
* Pauses playback on selected event
*/
static pause() {
if (eventLoader.selectedEventId) {
eventTimer.pause();
const newState = eventTimer.playback;
socketProvider.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
}
socketProvider.broadcastState();
}
/**
* Stops timer and unloads any events
*/
static stop() {
if (eventLoader.selectedEventId || eventTimer.playback === 'roll') {
eventLoader.reset();
eventTimer.stop();
const newState = eventTimer.playback;
socketProvider.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
}
socketProvider.broadcastState();
}
/**
* Reloads current event
*/
static reload() {
if (eventLoader.selectedEventId) {
this.loadById(eventLoader.selectedEventId);
}
socketProvider.broadcastState();
}
/**
* Sets playback to roll
*/
static roll() {
if (EventLoader.getPlayableEvents()) {
const rollTimers = eventLoader.findRoll(TimerService.getCurrentTime());
// nothing to play
if (rollTimers === null) {
socketProvider.error('SERVER', 'Roll: no events found');
PlaybackService.stop();
return;
}
const { currentEvent, nextEvent, timers } = rollTimers;
if (!currentEvent && !nextEvent) {
socketProvider.error('SERVER', 'Roll: no events found');
PlaybackService.stop();
return;
}
eventTimer.roll(currentEvent, nextEvent, timers);
const newState = eventTimer.playback;
socketProvider.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
socketProvider.broadcastState();
}
}
/**
* Adds delay to current event
* @param {number} delayTime time in minutes
*/
static setDelay(delayTime) {
if (eventLoader.selectedEventId) {
const delayInMs = delayTime * 1000 * 60;
eventTimer.delay(delayInMs);
socketProvider.info('PLAYBACK', `Added ${delayTime} min delay`);
}
socketProvider.broadcastState();
}
}
+267
View File
@@ -0,0 +1,267 @@
import { generateId } from 'ontime-utils';
import { DataProvider } from '../classes/data-provider/DataProvider.js';
import {
block as blockDef,
delay as delayDef,
event as eventDef,
} from '../models/eventsDefinition.js';
import { MAX_EVENTS } from '../settings.js';
import { EventLoader, eventLoader } from '../classes/event-loader/EventLoader.js';
import { eventTimer } from './TimerService.js';
import { socketProvider } from '../classes/socket/SocketController.js';
/**
*
* @param affectedIds
* @returns boolean
*/
const affectedLoaded = (affectedIds) => {
const now = eventLoader.selectedEventId;
const nowPublic = eventLoader.selectedPublicEventId;
const next = eventLoader.nextEventId;
const nextPublic = eventLoader.nextPublicEventId;
return (
affectedIds.includes(now) ||
affectedIds.includes(nowPublic) ||
affectedIds.includes(next) ||
affectedIds.includes(nextPublic)
);
};
const isNewNext = () => {
const timedEvents = EventLoader.getTimedEvents();
const now = eventLoader.selectedEventId;
const next = eventLoader.nextEventId;
// check whether the index of now and next are consecutive
const indexNow = timedEvents.findIndex((event) => event.id === now);
const indexNext = timedEvents.findIndex((event) => event.id === next);
if (indexNext - indexNow !== 1) {
return true;
}
// iterate through timed events and see if there are public events between nowPublic and nextPublic
const nowPublic = eventLoader.selectedPublicEventId;
const nextPublic = eventLoader.nextPublicEventId;
let foundNew = false;
let isAfter = false;
for (const event of timedEvents) {
if (!isAfter) {
if (event.id === nowPublic) {
isAfter = true;
}
} else {
if (event.id === nextPublic) {
break;
}
if (event.isPublic) {
foundNew = true;
break;
}
}
}
return foundNew;
};
/**
* updates timer object
* @param {array} [affectedIds]
*/
export function updateTimer(affectedIds) {
const runningEventId = eventLoader.selectedEventId;
if (runningEventId === null) {
return false;
}
// we need to reload in a few scenarios:
// 1. we are not confident that changes do not affect running event
const safeOption = typeof affectedIds === 'undefined';
// 2. the edited event is in memory (now or next) running
const eventInMemory = safeOption ? false : affectedLoaded(affectedIds);
// 3. the edited event replaces next event
const isNext = isNewNext();
if (safeOption) {
eventLoader.reset();
const { loadedEvent } = eventLoader.loadById(runningEventId) || {};
eventTimer.hotReload(loadedEvent);
return true;
}
if (eventInMemory) {
eventLoader.reset();
const { loadedEvent } = eventLoader.loadById(runningEventId) || {};
if (!loadedEvent) {
eventTimer.stop();
} else {
eventTimer.hotReload(loadedEvent);
}
return true;
}
if (isNext) {
const { loadedEvent } = eventLoader.loadById(runningEventId) || {};
eventTimer.hotReload(loadedEvent);
return true;
}
return false;
}
/**
* @description creates a new event with given data
* @param {object} eventData
* @return {unknown[]}
*/
export async function addEvent(eventData) {
const numEvents = DataProvider.getRundownLength();
if (numEvents > MAX_EVENTS) {
throw new Error(`ERROR: Reached limit number of ${MAX_EVENTS} events`);
}
let newEvent = {};
const id = generateId();
switch (eventData.type) {
case 'event':
newEvent = { ...eventDef, ...eventData, id };
break;
case 'delay':
newEvent = { ...delayDef, ...eventData, id };
break;
case 'block':
newEvent = { ...blockDef, ...eventData, id };
break;
}
try {
const afterId = newEvent?.after;
if (typeof afterId === 'undefined') {
await DataProvider.insertEventAt(newEvent, 0);
} else {
delete newEvent.after;
await DataProvider.insertEventAfterId(newEvent, afterId);
}
} catch (error) {
throw new Error(error);
}
updateTimer([id]);
socketProvider.broadcastState();
return newEvent;
}
export async function editEvent(eventData) {
const eventId = eventData.id;
const eventInMemory = DataProvider.getEventById(eventId);
if (typeof eventInMemory === 'undefined') {
throw new Error('No event with ID found');
}
const newEvent = await DataProvider.updateEventById(eventId, eventData);
updateTimer([eventId]);
socketProvider.broadcastState();
return newEvent;
}
/**
* deletes event by its ID
* @param eventId
* @returns {Promise<void>}
*/
export async function deleteEvent(eventId) {
await DataProvider.deleteEvent(eventId);
updateTimer([eventId]);
socketProvider.broadcastState();
}
/**
* deletes all events in database
* @returns {Promise<void>}
*/
export async function deleteAllEvents() {
await DataProvider.clearRundown();
updateTimer();
socketProvider.broadcastState();
}
/**
* reorders a given event
* @param {string} eventId
* @param {number} from
* @param {number} to
* @returns {Promise<void>}
*/
export async function reorderEvent(eventId, from, to) {
const rundown = DataProvider.getRundown();
const index = rundown.findIndex((event) => event.id === eventId);
if (index !== from) {
throw new Error('ID not found at index');
}
const [reorderedItem] = rundown.splice(from, 1);
// reinsert item at to
rundown.splice(to, 0, reorderedItem);
// save rundown
await DataProvider.setRundown(rundown);
updateTimer();
return reorderedItem;
}
/**
* applies delay value for given event
* @param eventId
* @returns {Promise<void>}
*/
export async function applyDelay(eventId) {
const rundown = DataProvider.getRundown();
// AUX
let delayIndex = null;
let blockIndex = null;
let delayValue = 0;
for (const [index, e] of rundown.entries()) {
// look for delay
if (delayIndex === null) {
if (e.id === eventId && e.type === 'delay') {
delayValue = e.duration;
delayIndex = index;
}
}
// apply delay value to all items until block or end
else {
if (e.type === 'event') {
// update times
e.timeStart += delayValue;
e.timeEnd += delayValue;
// increment revision
e.revision += 1;
} else if (e.type === 'block') {
// save id and stop
blockIndex = index;
break;
}
}
}
if (delayIndex === null) {
throw new Error(`Delay event with ID ${eventId} not found`);
}
// delete delay
rundown.splice(delayIndex, 1);
// delete block
// index would have moved down since we deleted delay
if (blockIndex) rundown.splice(blockIndex - 1, 1);
// update rundown
await DataProvider.setRundown(rundown);
updateTimer();
socketProvider.broadcastState();
}
+338
View File
@@ -0,0 +1,338 @@
import { runtimeState } from '../stores/EventStore.js';
import { PlaybackService } from './PlaybackService.js';
import { updateRoll } from './rollUtils.js';
import { DAY_TO_MS } from '../utils/time.js';
export class TimerService {
/**
* @constructor
* @param {object} [timerConfig]
* @param {number} [timerConfig.refresh]
*/
constructor(timerConfig) {
this._clear();
this._interval = setInterval(() => this.update(), timerConfig?.refresh || 1000);
}
/**
* Get current time in ms from midnight
* @static
* @return {number}
*/
static getCurrentTime() {
const now = new Date();
// extract milliseconds since midnight
let elapsed = now.getHours() * 3600000;
elapsed += now.getMinutes() * 60000;
elapsed += now.getSeconds() * 1000;
elapsed += now.getMilliseconds();
return elapsed;
}
/**
* Returns expected time finish
* @private
*/
_getExpectedFinish() {
if (this.timer.startedAt === null) {
return null;
}
if (this.timer.finishedAt) {
return this.timer.finishedAt;
}
return Math.max(
this.timer.startedAt + this.timer.duration + this._pausedInterval + this.timer.addedTime,
this.timer.startedAt
);
}
/**
* Clears internal state
* @private
*/
_clear() {
this.playback = 'stop';
this.timer = {
clock: TimerService.getCurrentTime(),
current: null,
elapsed: null,
expectedFinish: null,
addedTime: 0,
startedAt: null,
finishedAt: null,
secondaryTimer: null,
};
this.loadedTimerId = null;
this._pausedInterval = 0;
this._pausedAt = null;
this._secondaryTarget = null;
}
/**
* Reloads information for currently running timer
* @param timer
*/
hotReload(timer) {
if (typeof timer === 'undefined') {
this.stop();
return;
}
if (timer?.id !== this.loadedTimerId) {
// event timer only concerns itself with current event
return;
}
if (timer?.skip) {
this.stop();
}
// TODO: check if any relevant information warrants update
// update relevant information and force update
this.timer.duration = timer.duration;
// this might not be ideal
this.timer.finishedAt = null;
this.timer.expectedFinish = this._getExpectedFinish();
if (this.timer.startedAt === null) {
this.timer.current = timer.duration;
}
this.update();
}
/**
* Loads given timer to object
* @param {object} timer
* @param {number} timer.id
* @param {number} timer.timeStart
* @param {number} timer.timeEnd
* @param {number} timer.duration
* @param {string} timer.timeType
* @param {boolean} timer.skip
*/
load(timer) {
if (timer.skip) {
throw new Error('Refuse load of skipped event');
}
this._clear();
this.loadedTimerId = timer.id;
this.timer.duration = timer.duration;
this.timer.current = timer.duration;
this.playback = 'armed';
this._pausedInterval = 0;
this._pausedAt = 0;
this._onLoad();
}
/**
* Handles side effects related to onLoad event
* @private
*/
_onLoad() {
runtimeState.set('playback', this.playback);
runtimeState.set('ontime-timer', this.timer);
}
start() {
if (!this.loadedTimerId) {
return;
}
if (this.playback === 'play') {
return;
}
this.timer.clock = TimerService.getCurrentTime();
// add paused time
if (this._pausedInterval) {
this.timer.addedTime += this._pausedInterval;
this._pausedAt = null;
this._pausedInterval = 0;
} else {
this.timer.startedAt = this.timer.clock;
}
this.playback = 'play';
this.timer.expectedFinish = this._getExpectedFinish();
this._onStart();
}
/**
* Handles side effects related to onStart event
* @private
*/
_onStart() {
runtimeState.set('playback', this.playback);
runtimeState.set('ontime-timer', this.timer);
}
pause() {
if (this.playback !== 'play') {
return;
}
this.playback = 'pause';
this.timer.clock = TimerService.getCurrentTime();
this._pausedAt = this.timer.clock;
this._onPause();
}
_onPause() {
runtimeState.set('playback', this.playback);
runtimeState.set('ontime-timer', this.timer);
}
stop() {
if (this.playback === 'stop') {
return;
}
this._clear();
this._onStop();
}
_onStop() {
runtimeState.set('playback', this.playback);
runtimeState.set('ontime-timer', this.timer);
}
/**
* Delays running timer by given amount
* @param {number} amount
*/
delay(amount) {
if (!this.loadedTimerId) {
return;
}
this.timer.addedTime += amount;
this.timer.current += amount;
this.timer.elapsed += amount;
// handle edge cases
if (amount < 0 && Math.abs(amount) > this.timer.current) {
if (this.timer.finishedAt === null) {
// if we will make the clock negative
this.timer.finishedAt = TimerService.getCurrentTime();
}
} else if (this.timer.current < 0 && this.timer.current + amount > 0) {
// clock will go from negative to positive
this.timer.finishedAt = null;
}
// force an update
this.update();
}
update() {
this.timer.clock = TimerService.getCurrentTime();
if (this.playback === 'roll') {
const tempCurrentTimer = {
selectedEventId: this.loadedTimerId,
current: this.timer.current,
// safeguard on midnight rollover
_finishAt:
this.timer.expectedFinish >= this.timer.startedAt
? this.timer.expectedFinish
: this.timer.expectedFinish + DAY_TO_MS,
clock: this.timer.clock,
secondaryTimer: this.timer.secondaryTimer,
_secondaryTarget: this._secondaryTarget,
};
const { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished } =
updateRoll(tempCurrentTimer);
this.timer.current = updatedTimer;
this.timer.secondaryTimer = updatedSecondaryTimer;
if (isFinished) {
this.timer.selectedEventId = null;
this.loadedTimerId = null;
this._onFinish();
}
if (doRollLoad) {
PlaybackService.roll();
}
} else {
// we only update timer if a timer has been started
if (this.timer.startedAt !== null) {
if (this.playback === 'pause') {
this._pausedInterval = this.timer.clock - this._pausedAt;
}
this.timer.current =
this.timer.startedAt +
this.timer.duration +
this.timer.addedTime +
this._pausedInterval -
this.timer.clock;
this.timer.elapsed = this.timer.duration - this.timer.current;
if (this.playback === 'play' && this.timer.current <= 0 && this.timer.finishedAt === null) {
this.timer.finishedAt = this.timer.clock;
this._onFinish();
} else {
this.timer.finishedAt = null;
}
this.timer.expectedFinish = this._getExpectedFinish();
}
}
this._onUpdate();
}
_onUpdate() {
runtimeState.set('playback', this.playback);
runtimeState.set('ontime-timer', this.timer);
}
_onFinish() {
runtimeState.set('playback', this.playback);
runtimeState.set('ontime-timer', this.timer);
}
roll(currentEvent, nextEvent, timers) {
this._clear();
this.timer.clock = TimerService.getCurrentTime();
if (currentEvent) {
// there is something running, load
this.timer.secondaryTimer = null;
this._secondaryTarget = null;
this.loadedTimerId = currentEvent.id;
this.timer.startedAt = currentEvent.timeStart;
this.timer.expectedFinish = currentEvent.timeEnd;
this.timer.duration = timers.duration;
this.timer.current = timers.current;
} else if (nextEvent) {
// nothing now, but something coming up
this.timer.secondaryTimer = nextEvent.timeStart - this.timer.clock;
this._secondaryTarget = nextEvent.timeStart;
}
this.playback = 'roll';
this._onRoll();
this.update();
}
_onRoll() {
this._onLoad();
}
shutdown() {
clearInterval(this._interval);
}
}
export const eventTimer = new TimerService();
@@ -0,0 +1,658 @@
import {
DAY_TO_MS,
getRollTimers,
normaliseEndTime,
replacePlaceholder,
sortArrayByProperty,
updateRoll,
} from '../rollUtils.js';
// test sortArrayByProperty()
describe('sort simple arrays of objects', () => {
it('sort array 1-5', () => {
const arr1 = [{ timeStart: 1 }, { timeStart: 5 }, { timeStart: 3 }, { timeStart: 2 }, { timeStart: 4 }];
const arr1Expected = [{ timeStart: 1 }, { timeStart: 2 }, { timeStart: 3 }, { timeStart: 4 }, { timeStart: 5 }];
const sorted = sortArrayByProperty(arr1, 'timeStart');
expect(sorted).toStrictEqual(arr1Expected);
});
it('sort array 1-5 with null', () => {
const arr1 = [
{ timeStart: 1 },
{ timeStart: 5 },
{ timeStart: 3 },
{ timeStart: 2 },
{ timeStart: 4 },
{ timeStart: null },
];
const arr1Expected = [
{ timeStart: null },
{ timeStart: 1 },
{ timeStart: 2 },
{ timeStart: 3 },
{ timeStart: 4 },
{ timeStart: 5 },
];
const sorted = sortArrayByProperty(arr1, 'timeStart');
expect(sorted).toStrictEqual(arr1Expected);
});
});
// test getRollTimers()
describe('test that roll loads selection in right order', () => {
const eventlist = [
{
id: 1,
timeStart: 5,
timeEnd: 10,
isPublic: false,
},
{
id: 2,
timeStart: 10,
timeEnd: 20,
isPublic: false,
},
{
id: 3,
timeStart: 20,
timeEnd: 30,
isPublic: false,
},
{
id: 4,
timeStart: 30,
timeEnd: 40,
isPublic: false,
},
{
id: 5,
timeStart: 40,
timeEnd: 50,
isPublic: true,
},
{
id: 6,
timeStart: 50,
timeEnd: 60,
isPublic: false,
},
{
id: 7,
timeStart: 60,
timeEnd: 70,
isPublic: true,
},
{
id: 8,
timeStart: 70,
timeEnd: 80,
isPublic: false,
},
];
it('if timer is at 0', () => {
const now = 0;
const expected = {
nowIndex: null,
nowId: null,
publicIndex: null,
nextIndex: 0,
publicNextIndex: 4,
timers: null,
timeToNext: 5,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 5', () => {
const now = 5;
const expected = {
nowIndex: 0,
nowId: 1,
publicIndex: null,
nextIndex: 1,
publicNextIndex: 4,
timers: {
_finishAt: 10,
_startedAt: 5,
current: 5,
duration: 5,
},
timeToNext: 5,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 15', () => {
const now = 15;
const expected = {
nowIndex: 1,
nowId: 2,
publicIndex: null,
nextIndex: 2,
publicNextIndex: 4,
timers: {
_finishAt: 20,
_startedAt: 10,
current: 5,
duration: 10,
},
timeToNext: 5,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 20', () => {
const now = 20;
const expected = {
nowIndex: 2,
nowId: 3,
publicIndex: null,
nextIndex: 3,
publicNextIndex: 4,
timers: {
_startedAt: 20,
_finishAt: 30,
current: 10,
duration: 10,
},
timeToNext: 10,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 49', () => {
const now = 49;
const expected = {
nowIndex: 4,
nowId: 5,
publicIndex: 4,
nextIndex: 5,
publicNextIndex: 6,
timers: {
_startedAt: 40,
_finishAt: 50,
current: 1,
duration: 10,
},
timeToNext: 1,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 63', () => {
const now = 63;
const expected = {
nowIndex: 6,
nowId: 7,
publicIndex: 6,
nextIndex: 7,
publicNextIndex: null,
timers: {
_startedAt: 60,
_finishAt: 70,
current: 7,
duration: 10,
},
timeToNext: 7,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 75', () => {
const now = 75;
const expected = {
nowIndex: 7,
nowId: 8,
publicIndex: 6,
nextIndex: null,
publicNextIndex: null,
timers: {
_startedAt: 70,
_finishAt: 80,
current: 5,
duration: 10,
},
timeToNext: null,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 100', () => {
const now = 100;
const expected = {
nowIndex: null,
nowId: null,
publicIndex: null,
nextIndex: 0,
publicNextIndex: 4,
timers: null,
timeToNext: DAY_TO_MS - now + eventlist[0].timeStart,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('handles rolls to next day with real values', () => {
const singleEventList = [
{
id: 1,
timeStart: 36000000, // 10:00
timeEnd: 39600000, // 11:00
isPublic: true,
},
];
const now = 64800000; // 18:00
const expected = {
nowIndex: null,
nowId: null,
publicIndex: null,
nextIndex: 0,
publicNextIndex: 0,
timers: null,
timeToNext: DAY_TO_MS - now + singleEventList[0].timeStart,
};
const state = getRollTimers(singleEventList, now);
expect(state).toStrictEqual(expected);
});
});
// test getRollTimers()
describe('test that roll behaviour with overlapping times', () => {
const eventlist = [
{
id: 1,
timeStart: 10,
timeEnd: 10,
isPublic: false,
},
{
id: 2,
timeStart: 10,
timeEnd: 20,
isPublic: true,
},
{
id: 3,
timeStart: 10,
timeEnd: 30,
isPublic: false,
},
];
it('if timer is at 0', () => {
const now = 0;
const expected = {
nowIndex: null,
nowId: null,
publicIndex: null,
nextIndex: 0,
publicNextIndex: 1,
timers: null,
timeToNext: 10,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 10', () => {
const now = 10;
const expected = {
nowIndex: 1,
nowId: 2,
publicIndex: 1,
nextIndex: 2,
publicNextIndex: null,
timers: {
_finishAt: 20,
_startedAt: 10,
current: 10,
duration: 10,
},
timeToNext: 0,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 15', () => {
const now = 15;
const expected = {
nowIndex: 1,
nowId: 2,
publicIndex: 1,
nextIndex: 2,
publicNextIndex: null,
timers: {
_startedAt: 10,
_finishAt: 20,
current: 5,
duration: 10,
},
timeToNext: -5,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 20', () => {
const now = 20;
const expected = {
nowIndex: 2,
nowId: 3,
publicIndex: 1,
nextIndex: null,
publicNextIndex: null,
timers: {
_startedAt: 10,
_finishAt: 30,
current: 10,
duration: 20,
},
timeToNext: null,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 25', () => {
const now = 25;
const expected = {
nowIndex: 2,
nowId: 3,
publicIndex: 1,
nextIndex: null,
publicNextIndex: null,
timers: {
_startedAt: 10,
_finishAt: 30,
current: 5,
duration: 20,
},
timeToNext: null,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
});
// test replacePlaceholder()
describe('test that it replaces data correctly', () => {
const values = {
$timer: 'timer',
$title: 'title',
$presenter: 'presenter',
$subtitle: 'subtitle',
'$next-title': 'next title',
'$next-presenter': 'next presenter',
'$next-subtitle': 'next subtitle',
};
it('replaces timer', () => {
const test = '___1232132 $timer';
const expected = '___1232132 timer';
const s = replacePlaceholder(test, values);
expect(s).toBe(expected);
});
it('replaces title', () => {
const test = '___1232132 $title';
const expected = '___1232132 title';
const s = replacePlaceholder(test, values);
expect(s).toBe(expected);
});
it('replaces presenter', () => {
const test = '___1232132 $presenter';
const expected = '___1232132 presenter';
const s = replacePlaceholder(test, values);
expect(s).toBe(expected);
});
it('replaces subtitle', () => {
const test = '___1232132 $subtitle';
const expected = '___1232132 subtitle';
const s = replacePlaceholder(test, values);
expect(s).toBe(expected);
});
it('replaces next next title', () => {
const test = '___1232132 $next-title';
const expected = '___1232132 next title';
const s = replacePlaceholder(test, values);
expect(s).toBe(expected);
});
it('replaces next presenter', () => {
const test = '___1232132 $next-presenter';
const expected = '___1232132 next presenter';
const s = replacePlaceholder(test, values);
expect(s).toBe(expected);
});
it('replaces next subtitle', () => {
const test = '___1232132 $next-subtitle';
const expected = '___1232132 next subtitle';
const s = replacePlaceholder(test, values);
expect(s).toBe(expected);
});
});
// test getRollTimers() on issue #58
describe('test that roll behaviour multi day event edge cases', () => {
it('if the start time is the day after end time, and start time is earlier than now', () => {
const now = 66600000; // 19:30
const eventlist = [
{
id: 1,
timeStart: 66000000, // 19:20
timeEnd: 54600000, // 16:10
isPublic: false,
},
];
const expected = {
nowIndex: 0,
nowId: 1,
publicIndex: null,
nextIndex: null,
publicNextIndex: null,
timers: {
_startedAt: eventlist[0].timeStart,
_finishAt: eventlist[0].timeEnd,
current: eventlist[0].timeEnd + DAY_TO_MS - now,
duration: DAY_TO_MS - eventlist[0].timeStart + eventlist[0].timeEnd,
},
timeToNext: null,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
it('if the start time is the day after end time, and both are later than now', () => {
const now = 66840000; // 19:34
const eventlist = [
{
id: 1,
timeStart: 67200000, // 19:40
timeEnd: 66900000, // 19:35
isPublic: false,
},
];
const expected = {
nowIndex: null,
nowId: null,
publicIndex: null,
nextIndex: 0,
publicNextIndex: null,
timers: null,
timeToNext: eventlist[0].timeStart - now,
};
const state = getRollTimers(eventlist, now);
expect(state).toStrictEqual(expected);
});
});
// test normaliseEndTime() on issue #58
test('test typical scenarios', () => {
const t1 = {
start: 10,
end: 20,
};
const t1_expected = 20;
expect(normaliseEndTime(t1.start, t1.end)).toBe(t1_expected);
const t2 = {
start: 10 + DAY_TO_MS,
end: 20,
};
const t2_expected = 20 + DAY_TO_MS;
expect(normaliseEndTime(t2.start, t2.end)).toBe(t2_expected);
const t3 = {
start: 10,
end: 10,
};
const t3_expected = 10;
expect(normaliseEndTime(t3.start, t3.end)).toBe(t3_expected);
});
// test updateRoll()
describe('typical scenarios', () => {
it('it updates running events correctly', () => {
const timers = {
selectedEventId: 1,
current: 10,
_finishAt: 15,
clock: 11,
secondaryTimer: null,
_secondaryTarget: null,
};
const expected = {
updatedTimer: timers._finishAt - timers.clock,
updatedSecondaryTimer: null,
doRollLoad: false,
isFinished: false,
};
expect(updateRoll(timers)).toStrictEqual(expected);
// test that it can jump time
timers._finishAt = 1000;
timers.clock = 600;
expected.updatedTimer = timers._finishAt - timers.clock;
expect(updateRoll(timers)).toStrictEqual(expected);
});
it('it updates secondary timer', () => {
const timers = {
selectedEventId: null,
current: null,
_finishAt: null,
clock: 11,
secondaryTimer: 1,
_secondaryTarget: 15,
};
const expected = {
updatedTimer: null,
updatedSecondaryTimer: timers._secondaryTarget - timers.clock,
doRollLoad: false,
isFinished: false,
};
expect(updateRoll(timers)).toStrictEqual(expected);
});
it('flags an event end', () => {
const timers = {
selectedEventId: 1,
current: 10,
_finishAt: 11,
clock: 12,
secondaryTimer: null,
_secondaryTarget: null,
};
const expected = {
updatedTimer: timers._finishAt - timers.clock,
updatedSecondaryTimer: null,
doRollLoad: true,
isFinished: true,
};
expect(updateRoll(timers)).toStrictEqual(expected);
});
it('secondary events do not trigger event ends', () => {
const timers = {
selectedEventId: null,
current: null,
_finishAt: null,
clock: 16,
secondaryTimer: 1,
_secondaryTarget: 15,
};
const expected = {
updatedTimer: null,
updatedSecondaryTimer: timers._secondaryTarget - timers.clock,
doRollLoad: true,
isFinished: false,
};
expect(updateRoll(timers)).toStrictEqual(expected);
});
it('when a secondary timer is finished, it prompts for new event load', () => {
const timers = {
selectedEventId: null,
current: null,
_finishAt: null,
clock: 15,
secondaryTimer: 0,
_secondaryTarget: 15,
};
const expected = {
updatedTimer: null,
updatedSecondaryTimer: timers._secondaryTarget - timers.clock,
doRollLoad: true,
isFinished: false,
};
expect(updateRoll(timers)).toStrictEqual(expected);
});
});
+214
View File
@@ -0,0 +1,214 @@
/**
* Utility variable: 24 hour in milliseconds .
* @type {number}
*/
export const DAY_TO_MS = 86400000;
/**
* @description handle events that span over midnight
* @param {number} start - When does the event start
* @param {number} end - When does the event end
* @returns {number} normalised time
*/
export const normaliseEndTime = (start, end) => (end < start ? end + DAY_TO_MS : end);
/**
* @description Sorts an array of objects by given property
* @param {array} arr - array to be sorted
* @param {string} property - property to compare
* @returns {array} copy of array sorted in ascending order
*/
export const sortArrayByProperty = (arr, property) => {
return [...arr].sort((a, b) => {
return a[property] - b[property];
});
};
/**
* @description Replaces placeholder variables in string with given data
* @param {string} str - string to analyse
* @param {object} values - map of variables: values to use
* @returns {string} finished string
*/
export const replacePlaceholder = (str, values) => {
for (const [k, v] of Object.entries(values)) {
str = str.replace(k, v);
}
return str;
};
/**
*
* @param rundown
* @param timeNow
* @returns {{}}
*/
export const getRollTimers = (rundown, timeNow) => {
let nowIndex = null; // index of event now
let nowId = null; // id of event now
let publicIndex = null; // index of public event now
let publicTime = -1;
let nextIndex = null; // index of next event
let publicNextIndex = null; // index of next public event
let timeToNext = null; // counter: time for next event
let publicTimeToNext = null; // counter: time for next public event
let timers = null;
// Order events by startTime
const orderedEvents = sortArrayByProperty(rundown, 'timeStart');
// preload first if we are past events
const lastEvent = orderedEvents[orderedEvents.length - 1];
const lastNormalEnd = normaliseEndTime(lastEvent.timeStart, lastEvent.timeEnd);
let nextEvent = null;
let nextPublicEvent = null;
let currentEvent = null;
let currentPublicEvent = null;
if (timeNow > lastNormalEnd) {
nextIndex = 0;
timeToNext = orderedEvents[0].timeStart + DAY_TO_MS - timeNow;
// look for next public
for (const event of orderedEvents) {
if (event.isPublic) {
nextPublicEvent = event;
publicNextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
break;
}
}
} else {
// flags: select first event if several overlapping
let nowFound = false;
// loop through events, look for where we should be
for (const event of orderedEvents) {
// When does the event end (handle midnight)
const normalEnd = normaliseEndTime(event.timeStart, event.timeEnd);
if (normalEnd <= timeNow) {
// event ran already
// public event might not be the one running
if (event.isPublic && normalEnd > publicTime) {
publicTime = normalEnd;
currentPublicEvent = event;
publicIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
} else if (normalEnd > timeNow && timeNow >= event.timeStart && !nowFound) {
// event is running
// it could also be public
if (event.isPublic) {
publicTime = normalEnd;
currentPublicEvent = event;
publicIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
currentEvent = event;
nowIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
nowId = event.id;
// set timers
timers = {
_startedAt: event.timeStart,
_finishAt: event.timeEnd,
duration: normalEnd - event.timeStart,
current: normalEnd - timeNow,
};
nowFound = true;
} else if (normalEnd > timeNow) {
// event will run
// no need to look after found first
if (nextIndex !== null && publicNextIndex !== null) continue;
// look for next events
// check how far the start is from now
const wait = event.timeStart - timeNow;
if (nextIndex === null || wait < timeToNext) {
timeToNext = wait;
nextEvent = event;
nextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
if ((publicNextIndex === null || wait < publicTimeToNext) && event.isPublic) {
publicTimeToNext = wait;
nextPublicEvent = event;
publicNextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
}
}
}
return {
nowIndex,
nowId,
publicIndex,
nextIndex,
publicNextIndex,
timers,
timeToNext,
nextEvent,
nextPublicEvent,
currentEvent,
currentPublicEvent,
};
};
/**
* @description Implements update functions for roll mode
* @param {object} currentTimers
* @param {object} currentTimers.selectedEventId - Id of currently selected event
* @param {object} currentTimers.current - Running timer
* @param {object} currentTimers._finishAt - Expected finish time
* @param {object} currentTimers.clock - time now
* @param {object} currentTimers.secondaryTimer - secondary timer
* @param {object} currentTimers._secondaryTarget - finish time of secondary timer
* @returns {object} object with selection variables
*/
export const updateRoll = (currentTimers) => {
const { selectedEventId, current, _finishAt, clock, secondaryTimer, _secondaryTarget } =
currentTimers;
// timers
let updatedTimer = current;
let updatedSecondaryTimer = secondaryTimer;
// whether rollLoad should be called
let doRollLoad = false;
// whether finished event should trigger
let isFinished = false;
if (selectedEventId && current >= 0) {
// if we have something selected and a timer, we are running
// this is true because roll never goes into negative times
// update timer
updatedTimer = _finishAt - clock;
if (updatedTimer < 0) {
isFinished = true;
updatedTimer = null;
}
} else if (secondaryTimer >= 0) {
// if secondaryTimer is running we are in waiting to roll
// update secondary
updatedSecondaryTimer = _secondaryTarget - clock;
}
// if nothing is running, we need to find out if
// a) we just finished an event (finished was set to true)
// b) we need to look for events
// this could be caused by a secondary timer or event finished
const secondaryRunning = updatedSecondaryTimer <= 0 && updatedSecondaryTimer != null;
if (isFinished || secondaryRunning) {
// look for events
doRollLoad = true;
}
return { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished };
};