refactor: cuesheet v2 (#435)

* refactor: typescript migration

* chore: remove prop-types package

* refactor: file structure

* refactor: migrate ontime table to tanstack table 8

* refactor: rundown controller uses service as data source

* refactor: convert to typescript

* feat: caching store

* refactor: add delay values to rundown

* feat: toggle past visibility

* chore: update tests

* refactor: add extra fields to CSV

* style: show skipped events

* chore: add route to navigation menu

* style: allow jumping to bottom

* chore: add tests
This commit is contained in:
Carlos Valente
2023-07-22 09:32:40 +02:00
committed by GitHub
parent bee7a8dcb4
commit 3603b836f6
80 changed files with 3188 additions and 2434 deletions
+5 -4
View File
@@ -1,5 +1,6 @@
import { LogOrigin, OSCSettings } from 'ontime-types';
import { Server } from 'node-osc';
import { OSCSettings } from 'ontime-types';
import { IAdapter } from './IAdapter.js';
import { dispatchFromAdapter } from '../controllers/integrationController.js';
@@ -25,13 +26,13 @@ export class OscServer implements IAdapter {
// get first part before (ontime)
if (address !== 'ontime') {
logger.error('RX', `OSC IN: OSC messages to ontime must start with /ontime/, received: ${msg}`);
logger.error(LogOrigin.Rx, `OSC IN: OSC messages to ontime must start with /ontime/, received: ${msg}`);
return;
}
// get second part (command)
if (!path) {
logger.error('RX', 'OSC IN: No path found');
logger.error(LogOrigin.Rx, 'OSC IN: No path found');
return;
}
@@ -42,7 +43,7 @@ export class OscServer implements IAdapter {
this.osc.emit(topic, payload);
}
} catch (error) {
logger.error('RX', `OSC IN: ${error}`);
logger.error(LogOrigin.Rx, `OSC IN: ${error}`);
}
});
}
+6 -4
View File
@@ -14,6 +14,8 @@
* Payload: adds necessary payload for the request to be completed
*/
import { LogOrigin } from 'ontime-types';
import { WebSocket, WebSocketServer } from 'ws';
import getRandomName from '../utils/getRandomName.js';
@@ -47,7 +49,7 @@ export class SocketServer implements IAdapter {
this.wss.on('connection', (ws) => {
let clientId = getRandomName();
this.clientIds.add(clientId);
logger.info('CLIENT', `${this.wss.clients.size} Connections with new: ${clientId}`);
logger.info(LogOrigin.Client, `${this.wss.clients.size} Connections with new: ${clientId}`);
// send store payload on connect
ws.send(
@@ -67,7 +69,7 @@ export class SocketServer implements IAdapter {
ws.on('error', console.error);
ws.on('close', () => {
logger.info('CLIENT', `${this.wss.clients.size} Connections with disconnected: ${clientId}`);
logger.info(LogOrigin.Client, `${this.wss.clients.size} Connections with disconnected: ${clientId}`);
this.clientIds.delete(clientId);
});
@@ -96,7 +98,7 @@ export class SocketServer implements IAdapter {
clientId = payload;
this.clientIds.delete(previousName);
this.clientIds.add(clientId);
logger.info('CLIENT', `Client ${previousName} renamed to ${clientId}`);
logger.info(LogOrigin.Client, `Client ${previousName} renamed to ${clientId}`);
}
ws.send(
JSON.stringify({
@@ -127,7 +129,7 @@ export class SocketServer implements IAdapter {
ws.send(topic, payload);
}
} catch (error) {
logger.error('RX', `WS IN: ${error}`);
logger.error(LogOrigin.Rx, `WS IN: ${error}`);
}
} catch (_) {
// we ignore unknown
+6 -6
View File
@@ -9,7 +9,7 @@ import { join, resolve } from 'path';
import { currentDirectory, environment, externalsStartDirectory, isProduction, resolvedPath } from './setup.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
import { OSCSettings } from 'ontime-types';
import { LogOrigin, OSCSettings } from 'ontime-types';
// Import Routes
import { router as rundownRouter } from './routes/rundownRouter.js';
@@ -159,7 +159,7 @@ export const startOSCServer = async (overrideConfig = null) => {
const { osc } = DataProvider.getData();
if (!osc.enabledIn) {
logger.info('RX', 'OSC Input Disabled');
logger.info(LogOrigin.Rx, 'OSC Input Disabled');
return;
}
@@ -170,7 +170,7 @@ export const startOSCServer = async (overrideConfig = null) => {
};
// Start OSC Server
logger.info('RX', `Starting OSC Server on port: ${oscSettings.portIn}`);
logger.info(LogOrigin.Rx, `Starting OSC Server on port: ${oscSettings.portIn}`);
oscServer = new OscServer(oscSettings);
};
@@ -187,7 +187,7 @@ export const startIntegrations = async (config?: { osc: OSCSettings }) => {
}
const { success, message } = oscIntegration.init(osc);
logger.info('RX', message);
logger.info(LogOrigin.Rx, message);
if (success) {
integrationService.register(oscIntegration);
@@ -214,12 +214,12 @@ export const shutdown = async (exitCode = 0) => {
process.on('exit', (code) => console.log(`Ontime exited with code: ${code}`));
process.on('unhandledRejection', async (error) => {
logger.error('SERVER', `Error: unhandled rejection ${error}`);
logger.error(LogOrigin.Server, `Error: unhandled rejection ${error}`);
await shutdown(1);
});
process.on('uncaughtException', async (error) => {
logger.error('SERVER', `Error: uncaught exception ${error}`);
logger.error(LogOrigin.Server, `Error: uncaught exception ${error}`);
await shutdown(1);
});
@@ -2,7 +2,7 @@
* Class Event Provider is a mediator for handling the local db
* and adds logic specific to ontime data
*/
import { EventData, SupportedEvent, ViewSettings } from 'ontime-types';
import { EventData, ViewSettings } from 'ontime-types';
import { data, db } from '../../modules/loadDb.js';
import { safeMerge } from './DataProvider.utils.js';
@@ -27,31 +27,14 @@ export class DataProvider {
await this.persist();
}
static getIndexOf(eventId) {
return data.rundown.findIndex((e) => e.id === eventId);
}
static getEventById(eventId) {
return data.rundown.find((e) => e.id === eventId);
}
static async updateEventById(eventId, newData) {
const eventIndex = data.rundown.findIndex((e) => e.id === eventId);
const persistedEvent = data.rundown[eventIndex];
const newEvent = { ...persistedEvent, ...newData };
if (newEvent.type === SupportedEvent.Event) {
newEvent.revision++;
}
data.rundown[eventIndex] = newEvent;
await this.persist();
return data.rundown[eventIndex];
}
static async deleteEvent(eventId) {
const eventIndex = data.rundown.findIndex((e) => e.id === eventId);
if (eventIndex !== -1) {
data.rundown.splice(eventIndex, 1);
await this.persist();
}
}
static getRundownLength() {
return data.rundown.length;
}
@@ -62,53 +45,6 @@ export class DataProvider {
await db.write();
}
/**
* Insets an event after a given index
* @param entry
* @param index
* @return {Promise<void>}
*/
static async insertEventAt(entry, index) {
// get events
const events = DataProvider.getRundown();
const count = events.length;
const order = entry.order;
// Remove order field from object
delete entry.order;
// Insert at beginning
if (order === 0) {
events.unshift(entry);
}
// insert at end
else if (order >= count) {
events.push(entry);
}
// insert in the middle
else {
events.splice(index, 0, entry);
}
// save events
await DataProvider.setRundown(events);
}
/**
* @description Inserts an entry after an element with given ID
* @param entry
* @param id
* @return {Promise<void>}
*/
static async insertEventAfterId(entry, id) {
const index = [...data.rundown].findIndex((event) => event.id === id);
// eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars -- we are just getting rid of after parameter
const { after, ...sanitisedEvent } = entry;
await DataProvider.insertEventAt(sanitisedEvent, index + 1);
}
static getSettings() {
return data.settings;
}
@@ -1,4 +1,4 @@
import { Loaded, OntimeEvent, TitleBlock } from 'ontime-types';
import { Loaded, OntimeEvent, SupportedEvent, TitleBlock } from 'ontime-types';
import { DataProvider } from '../data-provider/DataProvider.js';
import { getRollTimers } from '../../services/rollUtils.js';
@@ -34,8 +34,7 @@ export class EventLoader {
* @return {array}
*/
static getTimedEvents(): OntimeEvent[] {
// return mockLoaderData.filter((event) => event.type === 'event');
return DataProvider.getRundown().filter((event) => event.type === 'event');
return DataProvider.getRundown().filter((event) => event.type === SupportedEvent.Event) as OntimeEvent[];
}
/**
@@ -43,8 +42,9 @@ export class EventLoader {
* @return {array}
*/
static getPlayableEvents(): OntimeEvent[] {
// return mockLoaderData.filter((event) => event.type === 'event' && !event.skip);
return DataProvider.getRundown().filter((event) => event.type === 'event' && !event.skip);
return DataProvider.getRundown().filter(
(event) => event.type === SupportedEvent.Event && !event.skip,
) as OntimeEvent[];
}
/**
@@ -1,6 +1,8 @@
import { Alias, EventData, LogOrigin } from 'ontime-types';
import fs from 'fs';
import type { Alias, EventData } from 'ontime-types';
import { networkInterfaces } from 'os';
import { fileHandler } from '../utils/parser.js';
import { DataProvider } from '../classes/data-provider/DataProvider.js';
import { failEmptyObjects, failIsNotArray } from '../utils/routerUtils.js';
@@ -10,7 +12,7 @@ import { eventStore } from '../stores/EventStore.js';
import { resolveDbPath } from '../setup.js';
import { oscIntegration } from '../services/integration-service/OscIntegration.js';
import { logger } from '../classes/Logger.js';
import { deleteAllEvents, forceReset } from '../services/RundownService.js';
import { deleteAllEvents, forceReset } from '../services/rundown-service/RundownService.js';
// Create controller for GET request to '/ontime/poll'
// Returns data for current state
@@ -281,7 +283,7 @@ export const postOscSubscriptions = async (req, res) => {
// TODO: this update could be more granular, checking that relevant data was changed
const { message } = oscIntegration.init(oscSettings);
logger.info('RX', message);
logger.info(LogOrigin.Rx, message);
res.send(oscSettings).status(200);
} catch (error) {
@@ -302,7 +304,7 @@ export const postOSC = async (req, res) => {
// TODO: this update could be more granular, checking that relevant data was changed
const { message } = oscIntegration.init(oscSettings);
logger.info('RX', message);
logger.info(LogOrigin.Rx, message);
res.send(oscSettings).status(200);
} catch (error) {
@@ -1,4 +1,4 @@
import { DataProvider } from '../classes/data-provider/DataProvider.ts';
import { OntimeEvent } from 'ontime-types';
import { failEmptyObjects } from '../utils/routerUtils.js';
import {
addEvent,
@@ -7,18 +7,14 @@ import {
deleteEvent,
editEvent,
reorderEvent,
} from '../services/RundownService.ts';
} from '../services/rundown-service/RundownService.js';
import { getDelayedRundown } from '../services/rundown-service/delayedRundown.utils.js';
// Create controller for GET request to '/events'
// Returns -
export const rundownGetAll = async (req, res) => {
res.json(DataProvider.getRundown());
};
// Create controller for GET request to '/events/:eventId'
// Returns -
export const getEventById = async (req, res) => {
res.json(DataProvider.getEventById(req.params?.eventId));
const delayedRundown = getDelayedRundown();
res.json(delayedRundown);
};
// Create controller for POST request to '/events/'
+1 -1
View File
@@ -1,6 +1,6 @@
import { EndAction, OntimeBlock, OntimeDelay, OntimeEvent, SupportedEvent, TimerType } from 'ontime-types';
export const event: Omit<OntimeEvent, 'id'> = {
export const event: Omit<OntimeEvent, 'id' | 'delay'> = {
title: '',
subtitle: '',
presenter: '',
-4
View File
@@ -1,7 +1,6 @@
import express from 'express';
import {
deleteEventById,
getEventById,
rundownApplyDelay,
rundownDelete,
rundownGetAll,
@@ -21,9 +20,6 @@ export const router = express.Router();
// create route between controller and '/events/' endpoint
router.get('/', rundownGetAll);
// create route between controller and '/events/:eventId' endpoint
router.get('/:eventId', paramsMustHaveEventId, getEventById);
// create route between controller and '/events/' endpoint
router.post('/', rundownPostValidator, rundownPost);
+20 -20
View File
@@ -1,4 +1,4 @@
import { OntimeEvent } from 'ontime-types';
import { LogOrigin, OntimeEvent, Playback } from 'ontime-types';
import { validatePlayback } from 'ontime-utils';
import { eventLoader, EventLoader } from '../classes/event-loader/EventLoader.js';
@@ -20,9 +20,9 @@ export class PlaybackService {
static loadEvent(event: OntimeEvent): boolean {
let success = false;
if (!event) {
logger.error('PLAYBACK', 'No event found');
logger.error(LogOrigin.Playback, 'No event found');
} else if (event.skip) {
logger.warning('PLAYBACK', `Refused playback of skipped event ID ${event.id}`);
logger.warning(LogOrigin.Playback, `Refused playback of skipped event ID ${event.id}`);
} else {
eventLoader.loadEvent(event);
eventTimer.load(event);
@@ -41,7 +41,7 @@ export class PlaybackService {
const event = EventLoader.getEventWithId(eventId);
const success = PlaybackService.loadEvent(event);
if (success) {
logger.info('PLAYBACK', `Loaded event with ID ${event.id}`);
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
PlaybackService.start();
}
return success;
@@ -56,7 +56,7 @@ export class PlaybackService {
const event = EventLoader.getEventAtIndex(eventIndex);
const success = PlaybackService.loadEvent(event);
if (success) {
logger.info('PLAYBACK', `Loaded event with ID ${event.id}`);
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
PlaybackService.start();
}
return success;
@@ -71,7 +71,7 @@ export class PlaybackService {
const event = EventLoader.getEventWithId(eventId);
const success = PlaybackService.loadEvent(event);
if (success) {
logger.info('PLAYBACK', `Loaded event with ID ${event.id}`);
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
}
return success;
}
@@ -85,7 +85,7 @@ export class PlaybackService {
const event = EventLoader.getEventAtIndex(eventIndex);
const success = PlaybackService.loadEvent(event);
if (success) {
logger.info('PLAYBACK', `Loaded event with ID ${event.id}`);
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
}
return success;
}
@@ -98,7 +98,7 @@ export class PlaybackService {
if (previousEvent) {
const success = PlaybackService.loadEvent(previousEvent);
if (success) {
logger.info('PLAYBACK', `Loaded event with ID ${previousEvent.id}`);
logger.info(LogOrigin.Playback, `Loaded event with ID ${previousEvent.id}`);
}
}
}
@@ -113,19 +113,19 @@ export class PlaybackService {
if (nextEvent) {
const success = PlaybackService.loadEvent(nextEvent);
if (success) {
logger.info('PLAYBACK', `Loaded event with ID ${nextEvent.id}`);
logger.info(LogOrigin.Playback, `Loaded event with ID ${nextEvent.id}`);
return true;
}
} else if (fallbackAction === 'stop') {
logger.info('PLAYBACK', 'No next event found! Stopping playback');
logger.info(LogOrigin.Playback, 'No next event found! Stopping playback');
PlaybackService.stop();
return false;
} else if (fallbackAction === 'pause') {
logger.info('PLAYBACK', 'No next event found! Pausing playback');
logger.info(LogOrigin.Playback, 'No next event found! Pausing playback');
PlaybackService.pause();
return false;
} else {
logger.info('PLAYBACK', 'No next event found! Continuing playback');
logger.info(LogOrigin.Playback, 'No next event found! Continuing playback');
return false;
}
}
@@ -137,7 +137,7 @@ export class PlaybackService {
if (validatePlayback(eventTimer.playback).start) {
eventTimer.start();
const newState = eventTimer.playback;
logger.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
}
}
@@ -159,7 +159,7 @@ export class PlaybackService {
if (validatePlayback(eventTimer.playback).pause) {
eventTimer.pause();
const newState = eventTimer.playback;
logger.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
}
}
@@ -171,7 +171,7 @@ export class PlaybackService {
eventLoader.reset();
eventTimer.stop();
const newState = eventTimer.playback;
logger.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
}
}
@@ -193,14 +193,14 @@ export class PlaybackService {
// nothing to play
if (rollTimers === null) {
logger.warning('SERVER', 'Roll: no events found');
logger.warning(LogOrigin.Server, 'Roll: no events found');
PlaybackService.stop();
return;
}
const { currentEvent, nextEvent } = rollTimers;
if (!currentEvent && !nextEvent) {
logger.warning('SERVER', 'Roll: no events found');
logger.warning(LogOrigin.Server, 'Roll: no events found');
PlaybackService.stop();
return;
}
@@ -208,7 +208,7 @@ export class PlaybackService {
eventTimer.roll(currentEvent, nextEvent);
const newState = eventTimer.playback;
logger.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
}
}
@@ -221,8 +221,8 @@ export class PlaybackService {
const delayInMs = delayTime * 1000 * 60;
eventTimer.delay(delayInMs);
delayInMs > 0
? logger.info('PLAYBACK', `Added ${delayTime} min delay`)
: logger.info('PLAYBACK', `Removed ${delayTime} min delay`);
? logger.info(LogOrigin.Playback, `Added ${delayTime} min delay`)
: logger.info(LogOrigin.Playback, `Removed ${delayTime} min delay`);
}
}
}
@@ -1,11 +1,30 @@
import { OntimeBaseEvent, OntimeBlock, OntimeDelay, OntimeEvent, SupportedEvent } from 'ontime-types';
import {
LogOrigin,
OntimeBaseEvent,
OntimeBlock,
OntimeDelay,
OntimeEvent,
OntimeRundown,
SupportedEvent,
} from 'ontime-types';
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 { sendRefetch } from '../adapters/websocketAux.js';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { block as blockDef, delay, 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 { sendRefetch } from '../../adapters/websocketAux.js';
import { runtimeCacheStore } from '../../stores/cachingStore.js';
import {
cachedAdd,
cachedDelete,
cachedEdit,
cachedReorder,
calculateRuntimeDelaysFrom,
delayedRundownCacheKey,
getDelayedRundown,
} from './delayedRundown.utils.js';
import { logger } from '../../classes/Logger.js';
/**
* Forces rundown to be recalculated
@@ -14,6 +33,7 @@ import { sendRefetch } from '../adapters/websocketAux.js';
export function forceReset() {
eventLoader.reset();
sendRefetch();
runtimeCacheStore.invalidate(delayedRundownCacheKey);
}
/**
@@ -73,7 +93,7 @@ const isNewNext = () => {
};
/**
* Updates timer object
* Updates timer service when a relevant piece of data changes
*/
export function updateTimer(affectedIds?: string[]) {
const runningEventId = eventLoader.loaded.selectedEventId;
@@ -130,44 +150,54 @@ export async function addEvent(eventData: Partial<OntimeEvent> | Partial<OntimeD
let newEvent: Partial<OntimeBaseEvent> = {};
const id = generateId();
// TODO: filter the parameters that exist in the event, use the parserUtils
switch (eventData.type) {
case 'event':
newEvent = { ...eventDef, ...eventData, id } as Partial<OntimeEvent>;
case SupportedEvent.Event:
newEvent = { ...eventDef, ...eventData, id };
break;
case 'delay':
newEvent = { ...delayDef, ...eventData, id } as Partial<OntimeDelay>;
case SupportedEvent.Delay:
newEvent = { ...delayDef, ...eventData, id };
break;
case 'block':
newEvent = { ...blockDef, ...eventData, id } as Partial<OntimeBlock>;
case SupportedEvent.Block:
newEvent = { ...blockDef, ...eventData, id };
break;
}
try {
const afterId = newEvent?.after;
if (typeof afterId === 'undefined') {
await DataProvider.insertEventAt(newEvent, 0);
let insertIndex = 0;
if (typeof newEvent?.after !== 'undefined') {
const index = DataProvider.getIndexOf(newEvent.after);
if (index < 0) {
logger.warning(LogOrigin.Server, `Could not find event with id ${newEvent.after}`);
} else {
delete newEvent.after;
await DataProvider.insertEventAfterId(newEvent, afterId);
insertIndex = index + 1;
}
} catch (error) {
throw new Error(error);
delete newEvent.after;
}
// modify rundown
await cachedAdd(insertIndex, newEvent as OntimeEvent | OntimeDelay | OntimeBlock);
// notify timer service of changed events
updateTimer([id]);
// notify event loader that rundown size has changed
updateChangeNumEvents();
// advice socket subscribers of change
sendRefetch();
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]);
export async function editEvent(eventData: Partial<OntimeEvent> | Partial<OntimeBlock> | Partial<OntimeDelay>) {
const newEvent = await cachedEdit(eventData.id, eventData);
// notify timer service of changed events
updateTimer([newEvent.id]);
// advice socket subscribers of change
sendRefetch();
return newEvent;
}
@@ -177,9 +207,18 @@ export async function editEvent(eventData) {
* @returns {Promise<void>}
*/
export async function deleteEvent(eventId) {
await DataProvider.deleteEvent(eventId);
await cachedDelete(eventId);
// notify timer service of changed events
updateTimer([eventId]);
// notify event loader that rundown size has changed
updateChangeNumEvents();
// invalidate cache
runtimeCacheStore.invalidate(delayedRundownCacheKey);
// advice socket subscribers of change
sendRefetch();
}
@@ -190,78 +229,83 @@ export async function deleteEvent(eventId) {
export async function deleteAllEvents() {
await DataProvider.clearRundown();
updateTimer();
updateChangeNumEvents();
sendRefetch();
forceReset();
}
/**
* reorders a given event
* @param {string} eventId
* @param {number} from
* @param {number} to
* @param {string} eventId - ID of event from, for sanity check
* @param {number} from - index of event from
* @param {number} to - index of event to
* @returns {Promise<void>}
*/
export async function reorderEvent(eventId, from, to) {
const rundown = DataProvider.getRundown();
const index = rundown.findIndex((event) => event.id === eventId);
export async function reorderEvent(eventId: string, from: number, to: number) {
const reorderedItem = await cachedReorder(eventId, from, to);
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);
// notify timer service of changed events
updateTimer();
// advice socket subscribers of change
sendRefetch();
return reorderedItem;
}
export function _applyDelay(
eventId: string,
rundown: OntimeRundown,
): {
delayIndex: number | null;
updatedRundown: OntimeRundown;
} {
const updatedRundown = [...rundown];
let delayIndex = null;
let delayValue = 0;
for (const [index, event] of updatedRundown.entries()) {
// look for delay
if (delayIndex === null) {
if (event.type === SupportedEvent.Delay && event.id === eventId) {
delayValue = event.duration;
delayIndex = index;
if (delayValue === 0) {
// nothing to apply
break;
}
}
continue;
}
// once delay is found, apply delay value to all items until block or end
if (event.type === SupportedEvent.Event) {
updatedRundown[index] = {
...event,
timeStart: Math.max(0, event.timeStart + delayValue),
timeEnd: Math.max(event.duration, event.timeEnd + delayValue),
revision: event.revision + 1,
};
} else if (event.type === SupportedEvent.Block) {
break;
}
}
return { delayIndex, updatedRundown };
}
/**
* applies delay value for given event
* @param eventId
* @returns {Promise<void>}
*/
export async function applyDelay(eventId: string) {
const rundown = DataProvider.getRundown();
let delayIndex = null;
let delayValue = 0;
for (const [index, event] of rundown.entries()) {
// look for delay
if (delayIndex === null) {
if (event.id === eventId && event.type === SupportedEvent.Delay) {
delayValue = event.duration;
delayIndex = index;
}
}
// apply delay value to all items until block or end
else {
if (event.type === SupportedEvent.Event) {
event.timeStart = Math.max(0, event.timeStart + delayValue);
event.timeEnd = Math.max(event.duration, event.timeStart + delayValue);
event.revision += 1;
} else if (event.type === SupportedEvent.Block) {
break;
}
}
}
const rundown: OntimeRundown = DataProvider.getRundown();
const { delayIndex, updatedRundown } = _applyDelay(eventId, rundown);
if (delayIndex === null) {
throw new Error(`Delay event with ID ${eventId} not found`);
}
// delete delay
rundown.splice(delayIndex, 1);
// update rundown
await DataProvider.setRundown(rundown);
updateTimer();
sendRefetch();
await DataProvider.setRundown(updatedRundown);
await deleteEvent(eventId);
}
/**
@@ -0,0 +1,319 @@
import { EndAction, OntimeRundown, SupportedEvent, TimerType } from 'ontime-types';
import { _applyDelay } from '../RundownService.js';
describe('applyDelay()', () => {
it('applies its duration to following events', () => {
const rundown: OntimeRundown = [
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 600000,
timeEnd: 1200000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 4,
id: '659e1',
},
{
duration: 600000,
type: SupportedEvent.Delay,
revision: 0,
id: '07986',
},
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 600000,
timeEnd: 1200000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 4,
id: 'd48c2',
},
];
const eventId = rundown[1].id;
const { delayIndex, updatedRundown } = _applyDelay(eventId, rundown);
expect(delayIndex).toBe(1);
// we do not delay delays anymore
expect(updatedRundown.length).toBe(3);
expect(rundown.length).toBe(3);
expect(updatedRundown[0].timeStart).toBe(rundown[0].timeStart);
expect(updatedRundown[2].timeStart).toBe(rundown[1].duration + rundown[2].timeStart);
expect(updatedRundown[2].timeEnd).toBe(rundown[1].duration + rundown[2].timeEnd);
});
it('stops propagating on blocks', () => {
const rundown: OntimeRundown = [
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 600000,
timeEnd: 1200000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 0,
id: '659e1',
},
{
duration: 600000,
type: SupportedEvent.Delay,
revision: 0,
id: '07986',
},
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 600000,
timeEnd: 1200000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 0,
id: 'd48c2',
},
{
title: '',
type: SupportedEvent.Block,
id: '9870d',
},
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 1200000,
timeEnd: 1800000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 2,
id: '2f185',
},
];
const eventId = rundown[1].id;
const { updatedRundown } = _applyDelay(eventId, rundown);
expect(updatedRundown[0].timeStart).toBe(rundown[0].timeStart);
expect(updatedRundown[2].timeStart).toBe(rundown[1].duration + rundown[2].timeStart);
expect(updatedRundown[4].timeStart).toBe(rundown[4].timeStart);
});
it('only applies given delay', () => {
const rundown: OntimeRundown = [
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 600000,
timeEnd: 1200000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 0,
id: '659e1',
},
{
duration: 600000,
type: SupportedEvent.Delay,
revision: 0,
id: '07986',
},
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 1200000,
timeEnd: 1200000,
duration: 0,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 0,
id: '1c48f',
},
{
duration: 1200000,
type: SupportedEvent.Delay,
revision: 0,
id: '7db42',
},
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 600000,
timeEnd: 1200000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 0,
id: 'd48c2',
},
{
title: '',
type: SupportedEvent.Block,
id: '9870d',
},
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 1200000,
timeEnd: 1800000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 0,
id: '2f185',
},
];
const eventId = rundown[1].id;
const { updatedRundown } = _applyDelay(eventId, rundown);
expect(updatedRundown[0].timeStart).toBe(rundown[0].timeStart);
expect(updatedRundown[2].timeStart).toBe(rundown[1].duration + rundown[2].timeStart);
expect(updatedRundown[4].timeStart).toBe(rundown[1].duration + rundown[4].timeStart);
});
});
@@ -0,0 +1,445 @@
import { EndAction, OntimeRundown, SupportedEvent, TimerType } from 'ontime-types';
import { calculateRuntimeDelays, calculateRuntimeDelaysFrom, getDelayAt } from '../delayedRundown.utils.js';
describe('calculateRuntimeDelays', () => {
it('calculates all delays in a given rundown', () => {
const rundown: OntimeRundown = [
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 600000,
timeEnd: 1200000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 0,
id: '659e1',
},
{
duration: 600000,
type: SupportedEvent.Delay,
revision: 0,
id: '07986',
},
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 1200000,
timeEnd: 1200000,
duration: 0,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 0,
id: '1c48f',
},
{
duration: 1200000,
type: SupportedEvent.Delay,
revision: 0,
id: '7db42',
},
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 600000,
timeEnd: 1200000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 0,
id: 'd48c2',
},
{
title: '',
type: SupportedEvent.Block,
id: '9870d',
},
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 1200000,
timeEnd: 1800000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 0,
id: '2f185',
},
];
const updatedRundown = calculateRuntimeDelays(rundown);
expect(rundown.length).toBe(updatedRundown.length);
expect(updatedRundown[0].delay).toBe(0);
expect(updatedRundown[2].delay).toBe(600000);
expect(updatedRundown[4].delay).toBe(600000 + 1200000);
expect(updatedRundown[6].delay).toBe(0);
});
});
describe('getDelayAt()', () => {
const delayedRundown: OntimeRundown = [
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 600000,
timeEnd: 1200000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 0,
id: '659e1',
delay: 0,
},
{
duration: 600000,
type: SupportedEvent.Delay,
revision: 0,
id: '07986',
},
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 1200000,
timeEnd: 1200000,
duration: 0,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 0,
id: '1c48f',
delay: 600000,
},
{
duration: 1200000,
type: SupportedEvent.Delay,
revision: 0,
id: '7db42',
},
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 600000,
timeEnd: 1200000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 0,
id: 'd48c2',
delay: 1800000,
},
{
title: '',
type: SupportedEvent.Block,
id: '9870d',
},
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 1200000,
timeEnd: 1800000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 0,
id: '2f185',
delay: 0,
},
];
it('calculates delay in a rundown', () => {
const delayAtStart = getDelayAt(0, delayedRundown);
const delayOnFirstEvent = getDelayAt(2, delayedRundown);
const delayOnSecondEvent = getDelayAt(4, delayedRundown);
const delayOnBlockedEvent = getDelayAt(0, delayedRundown);
expect(delayAtStart).toBe(0);
expect(delayOnFirstEvent).toBe(600000);
expect(delayOnSecondEvent).toBe(600000 + 1200000);
expect(delayOnBlockedEvent).toBe(0);
});
it('finds delay before a delay block', () => {
const valueOnFirstDelayBlock = getDelayAt(1, delayedRundown);
const valueOnSecondDelayBlock = getDelayAt(3, delayedRundown);
const valueAfterSecondDelayBlock = getDelayAt(4, delayedRundown);
expect(valueOnFirstDelayBlock).toBe(0);
expect(valueOnSecondDelayBlock).toBe(600000);
expect(valueAfterSecondDelayBlock).toBe(600000 + 1200000);
});
it('returns 0 after blocks', () => {
const valueOnBlock = getDelayAt(6, delayedRundown);
expect(valueOnBlock).toBe(0);
});
});
describe('calculateRuntimeDelaysFrom()', () => {
it('updates delays from given id', () => {
const delayedRundown: OntimeRundown = [
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 600000,
timeEnd: 1200000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 0,
id: '659e1',
delay: 0,
},
{
duration: 600000,
type: SupportedEvent.Delay,
revision: 0,
id: '07986',
},
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 1200000,
timeEnd: 1200000,
duration: 0,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 0,
id: '1c48f',
delay: 0,
},
{
duration: 1200000,
type: SupportedEvent.Delay,
revision: 0,
id: '7db42',
},
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 600000,
timeEnd: 1200000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 0,
id: 'd48c2',
delay: 1800000,
},
{
title: '',
type: SupportedEvent.Block,
id: '9870d',
},
{
title: '',
subtitle: '',
presenter: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStart: 1200000,
timeEnd: 1800000,
duration: 600000,
isPublic: true,
skip: false,
colour: '',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
type: SupportedEvent.Event,
revision: 0,
id: '2f185',
delay: 0,
},
];
const updatedRundown = calculateRuntimeDelaysFrom('07986', delayedRundown);
// we only update from the 4th on
expect(updatedRundown[0].delay).toBe(0);
// 1 + 3
expect(updatedRundown[4].delay).toBe(600000 + 1200000);
});
});
@@ -0,0 +1,261 @@
import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { getCached, runtimeCacheStore } from '../../stores/cachingStore.js';
import { isProduction } from '../../setup.js';
import { deleteAtIndex, insertAtIndex, reorderArray } from '../../utils/arrayUtils.js';
/**
* Key of rundown in cache
*/
export const delayedRundownCacheKey = 'delayed-rundown';
/**
* Invalidates the cached rundown when an inconsistency is found
* will throw when not in production
* @param errorMessage
*/
export function invalidateFromError(errorMessage = 'Found mismatch between store and cache') {
if (isProduction) {
runtimeCacheStore.invalidate(delayedRundownCacheKey);
} else {
throw new Error(errorMessage);
}
}
/**
* Returns rundown with calculated delays
* Ensures request goes through the caching layer
*/
export function getDelayedRundown(): OntimeRundown {
function calculateRundown() {
const rundown = DataProvider.getRundown();
return calculateRuntimeDelays(rundown);
}
return getCached(delayedRundownCacheKey, calculateRundown);
}
/**
* Adds an event in the rundown at given index, ensuring replication to delayed rundown cache
* @param eventIndex
* @param event
*/
export async function cachedAdd(eventIndex: number, event: OntimeEvent | OntimeDelay | OntimeBlock) {
// TODO: create wrapper function
const rundown = DataProvider.getRundown();
const newRundown = insertAtIndex(eventIndex, event, rundown);
const delayedRundown = getDelayedRundown();
let newDelayedRundown = insertAtIndex(eventIndex, event, delayedRundown);
// update delay cache
if (event.type === SupportedEvent.Event) {
// if it is an event, we need its delay
(newDelayedRundown[eventIndex] as OntimeEvent).delay = getDelayAt(eventIndex, newDelayedRundown);
} else {
// if it is a block or delay, we invalidate from here
newDelayedRundown = calculateRuntimeDelaysFromIndex(eventIndex, newDelayedRundown);
}
runtimeCacheStore.setCached(delayedRundownCacheKey, newDelayedRundown);
// we need to delay updating this to ensure add operation happens on same dataset
await DataProvider.setRundown(newRundown);
}
/**
* Edits an event in rundown, ensuring replication to delayed rundown cache
* @param eventId
* @param patchObject
*/
export async function cachedEdit(
eventId: string,
patchObject: Partial<OntimeEvent> | Partial<OntimeBlock> | Partial<OntimeDelay>,
) {
const indexInMemory = DataProvider.getIndexOf(eventId);
if (indexInMemory < 0) {
throw new Error('No event with ID found');
}
const updatedRundown = DataProvider.getRundown();
const newEvent = { ...updatedRundown[indexInMemory], ...patchObject };
if (newEvent.type === SupportedEvent.Event) {
newEvent.revision++;
}
// @ts-expect-error -- this merge is safe
updatedRundown[indexInMemory] = newEvent;
let newDelayedRundown = getDelayedRundown();
if (newDelayedRundown?.[indexInMemory].id !== newEvent.id) {
invalidateFromError();
} else {
// @ts-expect-error -- this merge is safe
newDelayedRundown[indexInMemory] = newEvent;
if (newEvent.type === SupportedEvent.Event) {
(newDelayedRundown[indexInMemory] as OntimeEvent).delay = getDelayAt(indexInMemory, newDelayedRundown);
} else if (newEvent.type === SupportedEvent.Delay) {
// blocks have no reason to change the rundown, from delays we need to recalculate
newDelayedRundown = calculateRuntimeDelaysFromIndex(indexInMemory, newDelayedRundown);
}
runtimeCacheStore.setCached(delayedRundownCacheKey, newDelayedRundown);
}
// we need to delay updating this to ensure edit operation happens on same dataset
await DataProvider.setRundown(updatedRundown);
return newEvent;
}
/**
* Deletes an event with given id from rundown, ensuring replication to delayed rundown cache
* @param eventId
*/
export async function cachedDelete(eventId: string) {
const eventIndex = DataProvider.getIndexOf(eventId);
let delayedRundown = getDelayedRundown();
if (eventIndex < 0) {
if (delayedRundown.findIndex((event) => event.id === eventId) >= 0) {
invalidateFromError();
}
return;
}
let updatedRundown = DataProvider.getRundown();
const eventType = updatedRundown[eventIndex].type;
updatedRundown = deleteAtIndex(eventIndex, updatedRundown);
if (eventId !== delayedRundown[eventIndex].id) {
invalidateFromError();
} else {
delayedRundown = deleteAtIndex(eventIndex, delayedRundown);
if (eventType === SupportedEvent.Delay || eventType === SupportedEvent.Block) {
// for events, we do not have to worry
// the following event, would have taken the place of the deleted event by now
delayedRundown = calculateRuntimeDelaysFromIndex(eventIndex, delayedRundown);
}
runtimeCacheStore.setCached(delayedRundownCacheKey, delayedRundown);
}
// we need to delay updating this to ensure edit operation happens on same dataset
await DataProvider.setRundown(updatedRundown);
}
/**
* Reorders an event in the rundown, ensuring replication to delayed rundown cache
* @param eventId
* @param from
* @param to
*/
export async function cachedReorder(eventId: string, from: number, to: number) {
const indexCheck = DataProvider.getIndexOf(eventId);
if (indexCheck !== from) {
invalidateFromError();
throw new Error('ID not found at index');
}
let updatedRundown = DataProvider.getRundown();
const reorderedEvent = updatedRundown[from];
updatedRundown = reorderArray(updatedRundown, from, to);
const delayedRundown = getDelayedRundown();
if (eventId !== delayedRundown[from].id) {
invalidateFromError();
} else {
// TODO: could we be more granular about updates
// I fear we need to update both from and to, which could signify more iterations
runtimeCacheStore.invalidate(delayedRundownCacheKey);
}
// we need to delay updating this to ensure edit operation happens on same dataset
await DataProvider.setRundown(updatedRundown);
return reorderedEvent;
}
/**
* Calculates all delays in a given rundown
* @param rundown
*/
export function calculateRuntimeDelays(rundown: OntimeRundown) {
let accumulatedDelay = 0;
const updatedRundown = [...rundown];
for (const [index, event] of updatedRundown.entries()) {
if (event.type === SupportedEvent.Delay) {
accumulatedDelay += event.duration;
} else if (event.type === SupportedEvent.Block) {
accumulatedDelay = 0;
} else if (event.type === SupportedEvent.Event) {
updatedRundown[index] = {
...event,
delay: accumulatedDelay,
};
}
}
return updatedRundown;
}
/**
* Calculate delays in rundown from a given index
* @param eventIndex
* @param rundown
*/
export function calculateRuntimeDelaysFromIndex(eventIndex: number, rundown: OntimeRundown) {
if (eventIndex === -1) {
throw new Error('ID not found at index');
}
let accumulatedDelay = getDelayAt(eventIndex, rundown);
const updatedRundown = [...rundown];
for (let i = eventIndex; i < rundown.length; i++) {
const event = rundown[i];
if (event.type === SupportedEvent.Delay) {
accumulatedDelay += event.duration;
} else if (event.type === SupportedEvent.Block) {
if (i === eventIndex) {
accumulatedDelay = 0;
} else {
break;
}
} else if (event.type === SupportedEvent.Event) {
updatedRundown[i] = {
...event,
delay: accumulatedDelay,
};
}
}
return updatedRundown;
}
/**
* Calculate delays in rundown from an event with given id
* @param eventId
* @param rundown
*/
export function calculateRuntimeDelaysFrom(eventId: string, rundown: OntimeRundown) {
const index = rundown.findIndex((event) => event.id === eventId);
return calculateRuntimeDelaysFromIndex(index, rundown);
}
/**
* Calculates delay to an event at a given index
* @param eventIndex
* @param rundown
*/
export function getDelayAt(eventIndex: number, rundown: OntimeRundown): number {
if (eventIndex < 1) {
return 0;
}
// we need to check the event before
const event = rundown[eventIndex - 1];
if (event.type === SupportedEvent.Delay) {
return event.duration + getDelayAt(eventIndex - 1, rundown);
} else if (event.type === SupportedEvent.Block) {
return 0;
} else if (event.type === SupportedEvent.Event) {
return event.delay ?? 0;
}
return 0;
}
@@ -0,0 +1,67 @@
import { runtimeCacheStore } from '../cachingStore.js';
describe('cachingStore()', () => {
beforeEach(() => {
runtimeCacheStore.clear(); // Clear the cache before each test
});
it('should check if an item is cached', () => {
// Add an item to the cache
runtimeCacheStore.setCached('key', 'value');
// Check if the item is cached
expect(runtimeCacheStore.checkCached('key')).toBe(true);
expect(runtimeCacheStore.checkCached('non-existent-key')).toBe(false);
});
it('should get an item from the cache', () => {
// Add an item to the cache
runtimeCacheStore.setCached('key', 'value');
// Get the item from the cache
const result = runtimeCacheStore.getCached('key', () => 'default-value');
// Check the returned value
expect(result).toBe('value');
});
it('should retrieve default value when item is not cached', () => {
// Get an item that is not in the cache
const result = runtimeCacheStore.getCached('non-existent-key', () => 'default-value');
// Check the returned value
expect(result).toBe('default-value');
});
it('should set an item in the cache', () => {
// Set an item in the cache
runtimeCacheStore.setCached('key', 'value');
// Check if the item is cached
expect(runtimeCacheStore.checkCached('key')).toBe(true);
});
it('should invalidate an item in the cache', () => {
// Add an item to the cache
runtimeCacheStore.setCached('key', 'value');
// Invalidate the item
runtimeCacheStore.invalidate('key');
// Check if the item is no longer cached
expect(runtimeCacheStore.checkCached('key')).toBe(false);
});
it('should clear the cache', () => {
// Add items to the cache
runtimeCacheStore.setCached('key1', 'value1');
runtimeCacheStore.setCached('key2', 'value2');
// Clear the cache
runtimeCacheStore.clear();
// Check if the cache is empty
expect(runtimeCacheStore.checkCached('key1')).toBe(false);
expect(runtimeCacheStore.checkCached('key2')).toBe(false);
});
});
+47
View File
@@ -0,0 +1,47 @@
interface CacheData {
data: unknown;
}
const runtimeCache: Map<string, CacheData> = new Map();
export function checkCached(key: string): boolean {
return runtimeCache.has(key);
}
export function getCached<T>(key: string, callback: () => T): T {
if (!runtimeCache.has(key)) {
try {
const data = callback();
runtimeCache.set(key, { data });
} catch (error) {
console.log(`Failed retrieving data from callback: ${error}`);
}
}
return runtimeCache.get(key).data as T;
}
export function setCached<T>(key: string, value: T): T {
runtimeCache.set(key, { data: value });
return runtimeCache.get(key).data as T;
}
export function invalidate(key) {
runtimeCache.delete(key);
}
export function clear() {
runtimeCache.clear();
}
function createCacheStore() {
return {
checkCached,
getCached,
setCached,
invalidate,
clear,
};
}
export const runtimeCacheStore = createCacheStore();
@@ -0,0 +1,54 @@
import { insertAtIndex, reorderArray } from '../arrayUtils.js';
describe('insertAtIndex', () => {
it('should insert an item at the beginning of the array', () => {
const array = [2, 3, 4];
const result = insertAtIndex(0, 1, array);
expect(result).toEqual([1, 2, 3, 4]);
});
it('should insert an item at the end of the array', () => {
const array = [1, 2, 3];
const result = insertAtIndex(3, 4, array);
expect(result).toEqual([1, 2, 3, 4]);
});
it('should insert an item in the middle of the array', () => {
const array = [1, 2, 4];
const result = insertAtIndex(2, 3, array);
expect(result).toEqual([1, 2, 3, 4]);
});
it('should return a new array and not modify the original array', () => {
const array = [1, 2, 3];
const result = insertAtIndex(1, 5, array);
expect(result).toEqual([1, 5, 2, 3]);
expect(array).toEqual([1, 2, 3]); // Original array should remain unchanged
});
});
describe('reorderArray', () => {
it('should reorder an item in the array', () => {
const array = ['a', 'b', 'c', 'd'];
const result = reorderArray(array, 1, 3);
expect(result).toEqual(['a', 'c', 'd', 'b']);
});
it('should return the original array if fromIndex and toIndex are the same', () => {
const array = ['a', 'b', 'c'];
const result = reorderArray(array, 1, 1);
expect(result).toEqual(array);
});
it('should handle reordering to the beginning of the array', () => {
const array = ['a', 'b', 'c'];
const result = reorderArray(array, 2, 0);
expect(result).toEqual(['c', 'a', 'b']);
});
it('should handle reordering to the end of the array', () => {
const array = ['a', 'b', 'c'];
const result = reorderArray(array, 0, 2);
expect(result).toEqual(['b', 'c', 'a']);
});
});
+50
View File
@@ -0,0 +1,50 @@
/**
* Inserts an item in an array at a given index
* @param index
* @param item
* @param array
*/
export function insertAtIndex<T>(index: number, item: T, array: T[]): T[] {
const modifiedArray = [...array];
// Insert at beginning
if (index === 0) {
modifiedArray.unshift(item);
}
// insert at end
else if (index >= modifiedArray.length) {
modifiedArray.push(item);
}
// insert in the middle
else {
modifiedArray.splice(index, 0, item);
}
return modifiedArray;
}
/**
* Deletes array element at a given index
* @param index
* @param array
*/
export function deleteAtIndex<T>(index: number, array: T[]) {
return array.filter((_, i) => i !== index);
}
export function reorderArray<T>(array: T[], fromIndex: number, toIndex: number) {
if (fromIndex === toIndex) {
return array; // No change needed, return the original array
}
const modifiedArray = [...array];
// delete in from
const [reorderedItem] = modifiedArray.splice(fromIndex, 1);
// reinsert item at to
modifiedArray.splice(toIndex, 0, reorderedItem);
return modifiedArray;
}