fix: apply delay (#494)

* fix: bug with applying delays

* fix: show delay data only in production screens

* chore: cover delay with feature test

* refactor: simplify type assertion
This commit is contained in:
Carlos Valente
2023-08-23 22:31:15 +02:00
committed by GitHub
parent 657cc22b44
commit e5fdf27f6c
16 changed files with 517 additions and 431 deletions
+29 -8
View File
@@ -1,38 +1,48 @@
# GETTING STARTED
Ontime consists of 3 distinct parts
- __client__: A React app for Ontime's UI and web clients
- __client__: A React app for Ontime's UI and web clients
- __electron__: An electron app which facilitates the cross-platform distribution of Ontime
- __server__: A node application which handles the domains services and integrations
The steps below will assume you have locally installed the necessary dependencies.
The steps below will assume you have locally installed the necessary dependencies.
Other dependencies will be installed as part of the setup
- __node__ (>=16.16)
- __pnpm__ (>=7)
- __docker__ (only necessary to run and build docker images)
## LOCAL DEVELOPMENT
The electron app is only necessary to distribute an installable version of the app and is not required for local development.
The electron app is only necessary to distribute an installable version of the app and is not required for local
development.
Locally, we would need to run both the React client and the node.js server in development mode
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Run dev mode__ by running `turbo dev`
### Debugging backend
To debug backend code in Node.js:
- Open two separate terminals and navigate to the `apps/client` and `apps/server` directories.
- In each terminal, run the command `pnpm dev` to start the development servers for both the client and server applications.
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by running `pnpm dev:inspect`.
- In each terminal, run the command `pnpm dev` to start the development servers for both the client and server
applications.
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by
running `pnpm dev:inspect`.
## TESTING
Generally we have 2 types of tests.
Generally we have 2 types of tests.
- Unit tests for functions that contain business logic
- End-to-end tests for core features
### Unit tests
Unit tests are contained in mostly all the apps and packages (client, server and utils)
You can run unit tests by running turbo `turbo test:pipeline` from the project root.
@@ -41,12 +51,20 @@ This will run all tests and close test runner.
Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode
### E2E tests
E2E tests are in a separate package. On running, [playwright](https://playwright.dev/) will spin up an instance of the webserver to test against
E2E tests are in a separate package. On running, [playwright](https://playwright.dev/) will spin up an instance of the
webserver to test against
These tests also run against a separate version of the DB (test-db)
You can run playwright tests from project root with `pnpm e2e`
When writing tests, it can be handy to run playwright in interactive mode with `pnpm e2e:i`. You would need to manually start the webserver with `pnpm dev:server`
When writing tests, it can be handy to run playwright in interactive mode with `pnpm e2e:i`. You would need to manually
start the webserver with `pnpm dev:server`
Some other useful commands
- `pnpm e2e --ui` open playwright UI
- `pnpm e2e --headed` run tests with a visible browser window
## CREATE AN INSTALLABLE FILE (Windows | MacOS | Linux)
@@ -54,6 +72,7 @@ Ontime uses Electron to distribute the application.
You can generate a distribution for your OS by running the following steps.
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Build the UI and server__ by running `turbo build:local`
- __Create the package__ by running `turbo dist-win`, `turbo dist-mac` or `turbo dist-linux`
@@ -66,10 +85,12 @@ Ontime provides a docker-compose file to aid with building and running docker im
While it should allow for a generic setup, it might need to be modified to fit your infrastructure.
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Build docker image from__ by running `docker build -t getontime/ontime`
- __Run docker image from compose__ by running `docker-compose up -d`
Other useful commands
- __List running processes__ by running `docker ps`
- __Kill running process__ by running `docker kill <process-id>`
@@ -37,7 +37,17 @@ function ButtonTooltip(name: TimeEntryField, warning?: string) {
}
export default function TimeInput(props: TimeInputProps) {
const { id, name, submitHandler, time = 0, delay = 0, placeholder, validationHandler, previousEnd = 0, warning } = props;
const {
id,
name,
submitHandler,
time = 0,
delay = 0,
placeholder,
validationHandler,
previousEnd = 0,
warning,
} = props;
const { emitError } = useEmitLog();
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>('');
@@ -191,7 +201,7 @@ export default function TimeInput(props: TimeInputProps) {
<Input
ref={inputRef}
id={id}
data-testid='time-input'
data-testid={`time-input-${name}`}
className={style.inputField}
type='text'
placeholder={placeholder}
@@ -6,10 +6,11 @@ import ScheduleItem from './ScheduleItem';
import './Schedule.scss';
interface ScheduleProps {
isProduction?: boolean;
className?: string;
}
export default function Schedule({ className }: ScheduleProps) {
export default function Schedule({ isProduction, className }: ScheduleProps) {
const { paginatedEvents, selectedEventId, isBackstage, scheduleType } = useSchedule();
if (paginatedEvents?.length < 1) {
@@ -30,12 +31,16 @@ export default function Schedule({ className }: ScheduleProps) {
selectedState = 'future';
}
}
const timeStart = isProduction ? event.timeStart + (event?.delay ?? 0) : event.timeStart;
const timeEnd = isProduction ? event.timeEnd + (event?.delay ?? 0) : event.timeEnd;
return (
<ScheduleItem
key={event.id}
selected={selectedState}
timeStart={event.timeStart + (event?.delay ?? 0)}
timeEnd={event.timeEnd + (event?.delay ?? 0)}
timeStart={timeStart}
timeEnd={timeEnd}
title={event.title}
colour={isBackstage ? event.colour : ''}
backstageEvent={!event.isPublic}
@@ -1,6 +1,6 @@
import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { OntimeRundown, OntimeRundownEntry, SupportedEvent } from 'ontime-types';
import { isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import { getCueCandidate, swapOntimeEvents } from 'ontime-utils';
import { RUNDOWN_TABLE, RUNDOWN_TABLE_KEY } from '../api/apiConstants';
@@ -59,7 +59,7 @@ export const useEventAction = () => {
const newEvent: Partial<OntimeRundownEntry> = { ...event };
// ************* CHECK OPTIONS specific to events
if (newEvent.type === SupportedEvent.Event) {
if (isOntimeEvent(newEvent)) {
const applicationOptions = {
defaultPublic: options?.defaultPublic ?? defaultPublic,
startTimeIsLastEnd: options?.startTimeIsLastEnd ?? startTimeIsLastEnd,
@@ -12,7 +12,7 @@ import {
} from '@dnd-kit/core';
import { horizontalListSortingStrategy, SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, OntimeRundownEntry, SupportedEvent } from 'ontime-types';
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import { useLocalStorage } from '../../common/hooks/useLocalStorage';
import { millisToDelayString } from '../../common/utils/dateConfig';
@@ -196,15 +196,14 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
<tbody>
{table.getRowModel().rows.map((row) => {
const entryType = row.original.type as SupportedEvent;
const key = row.original.id;
const isSelected = selectedId === key;
if (isSelected) {
isPast = false;
}
if (entryType === SupportedEvent.Block) {
const title = (row.original as OntimeBlock).title;
if (isOntimeBlock(row.original)) {
const title = row.original.title;
return (
<tr key={key} className={style.blockRow}>
@@ -212,8 +211,8 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
</tr>
);
}
if (entryType === SupportedEvent.Delay) {
const delayVal = (row.original as OntimeDelay).duration;
if (isOntimeDelay(row.original)) {
const delayVal = row.original.duration;
if (!showDelayBlock || delayVal === 0) {
return null;
@@ -226,7 +225,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
</tr>
);
}
if (entryType === SupportedEvent.Event) {
if (isOntimeEvent(row.original)) {
eventIndex++;
const isSelected = key === selectedId;
if (isSelected) {
@@ -238,9 +237,9 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
}
const bgFallback = 'transparent';
const bgColour = (row.original as OntimeEvent).colour || bgFallback;
const bgColour = row.original.colour || bgFallback;
const textColour = bgColour === bgFallback ? undefined : getAccessibleColour(bgColour);
const isSkipped = (row.original as OntimeEvent).skip;
const isSkipped = row.original.skip;
let rowBgColour: string | undefined;
if (row.original.id === selectedId) {
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { OntimeEvent, SupportedEvent } from 'ontime-types';
import { isOntimeEvent, OntimeEvent } from 'ontime-types';
import CopyTag from '../../common/components/copy-tag/CopyTag';
import { useEventAction } from '../../common/hooks/useEventAction';
@@ -29,8 +29,8 @@ export default function EventEditor() {
}
const event = data.find((event) => event.id === openId);
if (event && event.type === SupportedEvent.Event) {
setEvent(event as OntimeEvent);
if (event && isOntimeEvent(event)) {
setEvent(event);
}
}, [data, openId]);
@@ -166,7 +166,7 @@ export default function Backstage(props: BackstageProps) {
<ScheduleProvider events={filteredEvents} selectedEventId={selectedId} isBackstage>
<ScheduleNav className='schedule-nav-container' />
<Schedule className='schedule-container' />
<Schedule isProduction className='schedule-container' />
</ScheduleProvider>
<div className={showPublicMessage ? 'public-container' : 'public-container public-container--hidden'}>
@@ -0,0 +1,249 @@
import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types';
import { _applyDelay } from '../delayUtils.js';
describe('_applyDelay() ', () => {
describe('in a rundown without the delay field, persisted rundown', () => {
it('applies delays', () => {
const delayId = '1';
const testRundown: OntimeRundown = [
{ id: delayId, type: SupportedEvent.Delay, duration: 10 } as OntimeDelay,
{ id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
{ id: '3', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
{ id: '4', type: SupportedEvent.Block } as OntimeBlock,
{ id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
];
const expected = [
{ id: '2', type: SupportedEvent.Event, timeStart: 10, timeEnd: 20, duration: 10, revision: 2 } as OntimeEvent,
{ id: '3', type: SupportedEvent.Event, timeStart: 10, timeEnd: 20, duration: 10, revision: 2 } as OntimeEvent,
{ id: '4', type: SupportedEvent.Block } as OntimeBlock,
{ id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
];
const updatedRundown = _applyDelay(delayId, testRundown);
expect(updatedRundown).toStrictEqual(expected);
});
it('applies negative delays', () => {
const delayId = '1';
const testRundown: OntimeRundown = [
{ id: delayId, type: SupportedEvent.Delay, duration: -10 } as OntimeDelay,
{ id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
{ id: '3', type: SupportedEvent.Event, timeStart: 20, timeEnd: 40, duration: 20, revision: 1 } as OntimeEvent,
{ id: '4', type: SupportedEvent.Block } as OntimeBlock,
{ id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
];
const expected = [
{ id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 2 } as OntimeEvent,
{ id: '3', type: SupportedEvent.Event, timeStart: 10, timeEnd: 30, duration: 20, revision: 2 } as OntimeEvent,
{ id: '4', type: SupportedEvent.Block } as OntimeBlock,
{ id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
];
const updatedRundown = _applyDelay(delayId, testRundown);
expect(updatedRundown).toStrictEqual(expected);
});
it('maintains constant duration', () => {
const delayId = '1';
const testRundown: OntimeRundown = [
{ id: delayId, type: SupportedEvent.Delay, duration: -30 } as OntimeDelay,
{ id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
{ id: '3', type: SupportedEvent.Event, timeStart: 20, timeEnd: 40, duration: 20, revision: 1 } as OntimeEvent,
];
const expected = [
{ id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 2 } as OntimeEvent,
{ id: '3', type: SupportedEvent.Event, timeStart: 0, timeEnd: 20, duration: 20, revision: 2 } as OntimeEvent,
];
const updatedRundown = _applyDelay(delayId, testRundown);
expect(updatedRundown).toStrictEqual(expected);
});
});
describe('in a rundown with the delay field, cached rundown', () => {
it('applies delays', () => {
const delayId = '1';
const testRundown: OntimeRundown = [
{ id: delayId, type: SupportedEvent.Delay, duration: 10 } as OntimeDelay,
{
id: '2',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 1,
delay: 10,
} as OntimeEvent,
{
id: '3',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 1,
delay: 10,
} as OntimeEvent,
{ id: '4', type: SupportedEvent.Block } as OntimeBlock,
{
id: '5',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 1,
delay: 0,
} as OntimeEvent,
];
const expected = [
{
id: '2',
type: SupportedEvent.Event,
timeStart: 10,
timeEnd: 20,
duration: 10,
revision: 2,
delay: 0,
} as OntimeEvent,
{
id: '3',
type: SupportedEvent.Event,
timeStart: 10,
timeEnd: 20,
duration: 10,
revision: 2,
delay: 0,
} as OntimeEvent,
{ id: '4', type: SupportedEvent.Block } as OntimeBlock,
{
id: '5',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 1,
delay: 0,
} as OntimeEvent,
];
const updatedRundown = _applyDelay(delayId, testRundown);
expect(updatedRundown).toStrictEqual(expected);
});
it('applies negative delays', () => {
const delayId = '1';
const testRundown: OntimeRundown = [
{ id: delayId, type: SupportedEvent.Delay, duration: -10 } as OntimeDelay,
{
id: '2',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 1,
delay: -10,
} as OntimeEvent,
{
id: '3',
type: SupportedEvent.Event,
timeStart: 20,
timeEnd: 40,
duration: 20,
revision: 1,
delay: -10,
} as OntimeEvent,
{ id: '4', type: SupportedEvent.Block } as OntimeBlock,
{
id: '5',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 1,
delay: 0,
} as OntimeEvent,
];
const expected = [
{
id: '2',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 2,
delay: 0,
} as OntimeEvent,
{
id: '3',
type: SupportedEvent.Event,
timeStart: 10,
timeEnd: 30,
duration: 20,
revision: 2,
delay: 0,
} as OntimeEvent,
{ id: '4', type: SupportedEvent.Block } as OntimeBlock,
{
id: '5',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 1,
delay: 0,
} as OntimeEvent,
];
const updatedRundown = _applyDelay(delayId, testRundown);
expect(updatedRundown).toStrictEqual(expected);
});
it('maintains constant duration', () => {
const delayId = '1';
const testRundown: OntimeRundown = [
{ id: delayId, type: SupportedEvent.Delay, duration: -30 } as OntimeDelay,
{
id: '2',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 1,
delay: -30,
} as OntimeEvent,
{
id: '3',
type: SupportedEvent.Event,
timeStart: 20,
timeEnd: 40,
duration: 20,
revision: 1,
delay: -30,
} as OntimeEvent,
];
const expected = [
{
id: '2',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 10,
duration: 10,
revision: 2,
delay: 0,
} as OntimeEvent,
{
id: '3',
type: SupportedEvent.Event,
timeStart: 0,
timeEnd: 20,
duration: 20,
revision: 2,
delay: 0,
} as OntimeEvent,
];
const updatedRundown = _applyDelay(delayId, testRundown);
expect(updatedRundown).toStrictEqual(expected);
});
});
});
+37
View File
@@ -0,0 +1,37 @@
import { isOntimeBlock, isOntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types';
import { deleteAtIndex } from '../utils/arrayUtils.js';
export function _applyDelay(eventId: string, rundown: OntimeRundown): OntimeRundown {
const delayIndex = rundown.findIndex((event) => event.id === eventId);
const delayEvent = rundown.at(delayIndex);
if (delayEvent.type !== SupportedEvent.Delay) {
throw new Error('Given event ID is not a delay');
}
const updatedRundown = [...rundown];
const delayValue = delayEvent.duration;
if (delayValue === 0 || delayIndex === rundown.length - 1) {
// nothing to apply
return updatedRundown;
}
for (let i = delayIndex + 1; i < rundown.length; i++) {
const currentEvent = updatedRundown[i];
if (isOntimeBlock(currentEvent)) {
break;
} else if (isOntimeEvent(currentEvent)) {
currentEvent.timeStart = Math.max(0, currentEvent.timeStart + delayValue);
currentEvent.timeEnd = Math.max(currentEvent.duration, currentEvent.timeEnd + delayValue);
if (currentEvent.delay) {
currentEvent.delay = currentEvent.delay - delayValue;
}
currentEvent.revision += 1;
}
}
return deleteAtIndex(delayIndex, updatedRundown);
}
@@ -4,7 +4,6 @@ import {
OntimeBlock,
OntimeDelay,
OntimeEvent,
OntimeRundown,
Playback,
SupportedEvent,
} from 'ontime-types';
@@ -18,6 +17,7 @@ import { sendRefetch } from '../../adapters/websocketAux.js';
import { runtimeCacheStore } from '../../stores/cachingStore.js';
import {
cachedAdd,
cachedApplyDelay,
cachedClear,
cachedDelete,
cachedEdit,
@@ -166,7 +166,7 @@ export async function addEvent(eventData: Partial<OntimeEvent> | Partial<OntimeD
const id = generateId();
let insertIndex = 0;
if (eventData?.after !== 'undefined') {
if (eventData?.after !== undefined) {
const index = DataProvider.getIndexOf(eventData.after);
if (index < 0) {
logger.warning(LogOrigin.Server, `Could not find event with id ${eventData.after}`);
@@ -268,46 +268,14 @@ export async function reorderEvent(eventId: string, from: number, to: number) {
return reorderedItem;
}
export function _applyDelay(
eventId: string,
rundown: OntimeRundown,
): {
delayIndex: number | null;
updatedRundown: OntimeRundown;
} {
const updatedRundown = [...rundown];
let delayIndex = null;
let delayValue = 0;
export async function applyDelay(eventId: string) {
await cachedApplyDelay(eventId);
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;
// notify timer service of changed events
updateTimer();
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 };
// advice socket subscribers of change
sendRefetch();
}
/**
@@ -326,22 +294,6 @@ export async function swapEvents(from: string, to: string) {
sendRefetch();
}
/**
* applies delay value for given event
* @param eventId
* @returns {Promise<void>}
*/
export async function applyDelay(eventId: string) {
const rundown: OntimeRundown = DataProvider.getRundown();
const { delayIndex, updatedRundown } = _applyDelay(eventId, rundown);
if (delayIndex === null) {
throw new Error(`Delay event with ID ${eventId} not found`);
}
await DataProvider.setRundown(updatedRundown);
await deleteEvent(eventId);
}
/**
* Forces update in the store
* Called when we make changes to the rundown object
@@ -1,319 +0,0 @@
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);
});
});
@@ -1,10 +1,20 @@
import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types';
import {
isOntimeBlock,
isOntimeDelay,
isOntimeEvent,
OntimeBlock,
OntimeDelay,
OntimeEvent,
OntimeRundown,
OntimeRundownEntry,
} from 'ontime-types';
import { swapOntimeEvents } from 'ontime-utils';
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';
import { swapOntimeEvents } from 'ontime-utils';
import { _applyDelay } from '../delayUtils.js';
/**
* Key of rundown in cache
@@ -51,7 +61,7 @@ export async function cachedAdd(eventIndex: number, event: OntimeEvent | OntimeD
let newDelayedRundown = insertAtIndex(eventIndex, event, delayedRundown);
// update delay cache
if (event.type === SupportedEvent.Event) {
if (isOntimeEvent(event)) {
// if it is an event, we need its delay
(newDelayedRundown[eventIndex] as OntimeEvent).delay = getDelayAt(eventIndex, newDelayedRundown);
} else {
@@ -79,22 +89,20 @@ export async function cachedEdit(
}
const updatedRundown = DataProvider.getRundown();
const newEvent = { ...updatedRundown[indexInMemory], ...patchObject };
if (newEvent.type === SupportedEvent.Event) {
const newEvent = { ...updatedRundown[indexInMemory], ...patchObject } as OntimeRundownEntry;
if (isOntimeEvent(newEvent)) {
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) {
if (isOntimeEvent(newEvent)) {
(newDelayedRundown[indexInMemory] as OntimeEvent).delay = getDelayAt(indexInMemory, newDelayedRundown);
} else if (newEvent.type === SupportedEvent.Delay) {
} else if (isOntimeDelay(newEvent)) {
// blocks have no reason to change the rundown, from delays we need to recalculate
newDelayedRundown = calculateRuntimeDelaysFromIndex(indexInMemory, newDelayedRundown);
}
@@ -124,13 +132,12 @@ export async function cachedDelete(eventId: string) {
}
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) {
if (isOntimeDelay(updatedRundown[eventIndex]) || isOntimeBlock(updatedRundown[eventIndex])) {
// 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);
@@ -205,6 +212,19 @@ export async function cachedSwap(fromEventId: string, toEventId: string) {
await DataProvider.setRundown(rundownToUpdate);
}
export async function cachedApplyDelay(eventId: string) {
// update persisted rundown
const rundown: OntimeRundown = DataProvider.getRundown();
const persistedRundown = _applyDelay(eventId, rundown);
const delayedRundown = getDelayedRundown();
const cachedRundown = _applyDelay(eventId, delayedRundown);
// update
runtimeCacheStore.setCached(delayedRundownCacheKey, cachedRundown);
await DataProvider.setRundown(persistedRundown);
}
/**
* Calculates all delays in a given rundown
* @param rundown
@@ -214,11 +234,11 @@ export function calculateRuntimeDelays(rundown: OntimeRundown) {
const updatedRundown = [...rundown];
for (const [index, event] of updatedRundown.entries()) {
if (event.type === SupportedEvent.Delay) {
if (isOntimeDelay(event)) {
accumulatedDelay += event.duration;
} else if (event.type === SupportedEvent.Block) {
} else if (isOntimeBlock(event)) {
accumulatedDelay = 0;
} else if (event.type === SupportedEvent.Event) {
} else if (isOntimeEvent(event)) {
updatedRundown[index] = {
...event,
delay: accumulatedDelay,
@@ -243,15 +263,15 @@ export function calculateRuntimeDelaysFromIndex(eventIndex: number, rundown: Ont
for (let i = eventIndex; i < rundown.length; i++) {
const event = rundown[i];
if (event.type === SupportedEvent.Delay) {
if (isOntimeDelay(event)) {
accumulatedDelay += event.duration;
} else if (event.type === SupportedEvent.Block) {
} else if (isOntimeBlock(event)) {
if (i === eventIndex) {
accumulatedDelay = 0;
} else {
break;
}
} else if (event.type === SupportedEvent.Event) {
} else if (isOntimeEvent(event)) {
updatedRundown[i] = {
...event,
delay: accumulatedDelay,
@@ -284,11 +304,11 @@ export function getDelayAt(eventIndex: number, rundown: OntimeRundown): number {
// we need to check the event before
const event = rundown[eventIndex - 1];
if (event.type === SupportedEvent.Delay) {
if (isOntimeDelay(event)) {
return event.duration + getDelayAt(eventIndex - 1, rundown);
} else if (event.type === SupportedEvent.Block) {
} else if (isOntimeBlock(event)) {
return 0;
} else if (event.type === SupportedEvent.Event) {
} else if (isOntimeEvent(event)) {
return event.delay ?? 0;
}
return 0;
@@ -0,0 +1,94 @@
import { expect, test } from '@playwright/test';
test('delay blocks add time to events', async ({ page }) => {
await page.goto('http://localhost:4001/editor');
// delete all events and add a new one
await page.getByRole('button', { name: 'Event...' }).click();
await page.getByRole('menuitem', { name: 'Delete all events' }).click();
await page.getByRole('button', { name: 'Event...' }).click();
await page.getByRole('menuitem', { name: 'Add event at start' }).click();
// add data to new event
await page.getByTestId('panel-rundown').getByPlaceholder('Start').click();
await page.getByTestId('panel-rundown').getByPlaceholder('Start').fill('10m');
await page.getByTestId('panel-rundown').getByPlaceholder('Start').press('Enter');
await page.getByTestId('panel-rundown').getByPlaceholder('End').click();
await page.getByTestId('panel-rundown').getByPlaceholder('End').fill('20m');
await page.getByTestId('panel-rundown').getByPlaceholder('End').press('Enter');
// add delay block
await page.getByRole('button', { name: 'Event...' }).click();
await page.getByRole('menuitem', { name: 'Add delay at start' }).click();
// fill positive delay
await page.getByTestId('delay-input').click();
await page.getByTestId('delay-input').fill('2m');
await page.getByTestId('delay-input').press('Enter');
await page.getByText('+2 minNew start: 00:12:00').click();
// make negative delay
await page.getByText('Subtract time').click();
await page.getByText('-2 minNew start: 00:08:00').click();
// apply delay
await page.getByRole('button', { name: 'Apply' }).click();
await expect(page.getByTestId('panel-rundown').getByTestId('time-input-timeStart')).toHaveValue('00:08:00');
// add new delay
await page.getByTestId('panel-rundown').getByPlaceholder('Start').click();
await page.getByRole('button', { name: 'Event...' }).click();
await page.getByRole('menuitem', { name: 'Add delay at start' }).click();
await page.getByTestId('delay-input').click();
await page.getByTestId('delay-input').fill('10m');
await page.getByTestId('delay-input').press('Enter');
await page.getByText('+10 minNew start: 00:18:00').click();
// cancel delay
await page.getByRole('button', { name: 'Cancel' }).click();
await expect(page.getByTestId('panel-rundown').getByTestId('time-input-timeStart')).toHaveValue('00:08:00');
});
test('delays are show correctly', async ({ page }) => {
await page.goto('http://localhost:4001/editor');
// add a test event
await page.getByRole('button', { name: 'Event...' }).click();
await page.getByRole('menuitem', { name: 'Delete all events' }).click();
await page.getByRole('button', { name: 'Create Event' }).click();
await page.getByTestId('time-input-timeStart').click();
await page.getByTestId('panel-rundown').getByTestId('time-input-timeStart').click();
await page.getByTestId('panel-rundown').getByTestId('time-input-timeStart').fill('10');
await page.getByTestId('panel-rundown').getByTestId('time-input-timeStart').press('Enter');
await page.getByTestId('panel-rundown').getByTestId('time-input-timeEnd').click();
await page.getByTestId('panel-rundown').getByTestId('time-input-timeEnd').fill('20');
await page.getByTestId('panel-rundown').getByTestId('time-input-timeEnd').press('Enter');
await page.getByText('Event title').click();
await page.getByPlaceholder('Event title').fill('test');
await page.getByPlaceholder('Event title').press('Enter');
await page.getByText('SED').click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Toggle public' }).click();
// add a delay
await page.getByRole('button', { name: 'Event...' }).click();
await page.getByRole('menuitem', { name: 'Add delay at start' }).click();
await page.getByTestId('delay-input').click();
await page.getByTestId('delay-input').fill('1');
await page.getByTestId('delay-input').press('Enter');
// delay is shown in the editor
await page.getByText('+1 minNew start: 00:11:00').click();
// delay is shown in the cuesheet
await page.goto('http://localhost:4001/cuesheet');
await page.getByRole('cell', { name: '+1 min' }).click();
// delay is NOT shown in the public view
await page.goto('http://localhost:4001/public');
await page.getByText('00:10 → 00:20').click();
// delay is shown in the backstage view
await page.goto('http://localhost:4001/backstage');
await page.getByText('00:11 → 00:21').click();
});
+2 -1
View File
@@ -46,5 +46,6 @@ export type { TitleBlock } from './definitions/runtime/TitleBlock.type.js';
// CLIENT
// UTILITY TYPES
// TYPE UTILITIES
export { isOntimeBlock, isOntimeDelay, isOntimeEvent } from './utils/guards.js';
export type { MaybeNumber } from './utils/utils.type.js';
+14
View File
@@ -0,0 +1,14 @@
import { OntimeRundownEntry } from '../definitions/core/Rundown.type.js';
import { OntimeBlock, OntimeDelay, OntimeEvent, SupportedEvent } from '../definitions/core/OntimeEvent.type.js';
export function isOntimeEvent(event: OntimeRundownEntry | Partial<OntimeRundownEntry>): event is OntimeEvent {
return event.type === SupportedEvent.Event;
}
export function isOntimeDelay(event: OntimeRundownEntry | Partial<OntimeRundownEntry>): event is OntimeDelay {
return event.type === SupportedEvent.Delay;
}
export function isOntimeBlock(event: OntimeRundownEntry | Partial<OntimeRundownEntry>): event is OntimeBlock {
return event.type === SupportedEvent.Block;
}
@@ -1,4 +1,4 @@
import { OntimeEvent, OntimeRundown, OntimeRundownEntry, SupportedEvent } from 'ontime-types';
import { isOntimeEvent, OntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import { dayInMs } from '../timeConstants.js';
@@ -18,8 +18,9 @@ export function getFirst(rundown: OntimeRundownEntry[]) {
*/
export function getFirstEvent(rundown: OntimeRundownEntry[]) {
for (let i = 0; i < rundown.length; i++) {
if (rundown[i].type === SupportedEvent.Event) {
return rundown[i] as OntimeEvent;
const event = rundown[i];
if (isOntimeEvent(event)) {
return event;
}
}
return null;
@@ -53,8 +54,9 @@ export function getNextEvent(rundown: OntimeRundownEntry[], currentId: string):
}
for (let i = index + 1; i < rundown.length; i++) {
if (rundown[i].type === SupportedEvent.Event) {
return rundown[i] as OntimeEvent;
const event = rundown[i];
if (isOntimeEvent(event)) {
return event;
}
}
return null;
@@ -88,8 +90,9 @@ export function getPreviousEvent(rundown: OntimeRundownEntry[], currentId: strin
}
for (let i = index - 1; i >= 0; i--) {
if (rundown[i].type === SupportedEvent.Event) {
return rundown[i] as OntimeEvent;
const event = rundown[i];
if (isOntimeEvent(event)) {
return event;
}
}
return null;