feat: operator view

This commit is contained in:
cv
2023-08-28 21:42:27 +02:00
parent 9886e986d3
commit 32f18d8d23
32 changed files with 399 additions and 316 deletions
@@ -1,4 +1,5 @@
import { CSSProperties } from 'react';
import { ReactComponent as Emptyimage } from '@/assets/images/empty.svg';
import style from './Empty.module.scss';
@@ -2,7 +2,7 @@
@use '../../../theme/ontimeColours' as *;
.drawerContent {
background-color: $gray-1200;
background-color: $gray-1250;
}
.drawerHeader {
@@ -1,3 +1,5 @@
import { UserFields } from 'ontime-types';
import { ParamField } from './types';
export const TIME_FORMAT_OPTION: ParamField = {
@@ -202,30 +204,38 @@ export const STUDIO_CLOCK_OPTIONS: ParamField[] = [
},
];
export const OPERATOR_OPTIONS: ParamField[] = [
TIME_FORMAT_OPTION,
{
id: 'hidepast',
title: 'Hide Past Events',
description: 'Whether to events that have passed',
type: 'boolean',
},
{
id: 'subscribe',
title: 'Highlight Field',
description: 'Choose a field to highlight',
type: 'option',
values: {
user0: 'user0',
user1: 'user1',
user2: 'user2',
user3: 'user3',
user4: 'user4',
user5: 'user5',
user6: 'user6',
user7: 'user7',
user8: 'user8',
user9: 'user9',
export const getOperatorOptions = (userFields: UserFields): ParamField[] => {
return [
TIME_FORMAT_OPTION,
{
id: 'showseconds',
title: 'Show seconds',
description: 'Schedule shows hh:mm:ss',
type: 'boolean',
},
},
];
{
id: 'hidepast',
title: 'Hide Past Events',
description: 'Whether to events that have passed',
type: 'boolean',
},
{
id: 'subscribe',
title: 'Highlight Field',
description: 'Choose a field to highlight',
type: 'option',
values: {
user0: userFields.user0 || 'user0',
user1: userFields.user1 || 'user1',
user2: userFields.user2 || 'user2',
user3: userFields.user3 || 'user3',
user4: userFields.user4 || 'user4',
user5: userFields.user5 || 'user5',
user6: userFields.user6 || 'user6',
user7: userFields.user7 || 'user7',
user8: userFields.user8 || 'user8',
user9: userFields.user9 || 'user9',
},
},
];
};
@@ -0,0 +1,32 @@
// roughly from https://github.com/juliencrn/usehooks-ts/blob/master/packages/usehooks-ts/src/useMediaQuery/useMediaQuery.ts
import { useCallback, useEffect, useState } from 'react';
function getMatches(query: string): boolean {
return window.matchMedia(query).matches;
}
// TODO: debounce handleChange
export default function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(getMatches(query));
const handleChange = useCallback(() => {
setMatches(getMatches(query));
}, [query]);
useEffect(() => {
const matchMedia = window.matchMedia(query);
// Triggered at the first client-side load and if query changes
handleChange();
// Listen matchMedia
matchMedia.addEventListener('change', handleChange);
return () => {
matchMedia.removeEventListener('change', handleChange);
};
}, [handleChange, query]);
return matches;
}
+3 -3
View File
@@ -3,7 +3,7 @@ import Color from 'color';
type ColourCombination = {
backgroundColor: string;
color: string;
}
};
/**
* @description Selects text colour to maintain accessible contrast
@@ -19,11 +19,11 @@ export const getAccessibleColour = (bgColour: string): ColourCombination => {
console.log(`Unable to parse colour: ${bgColour}`);
}
}
return { backgroundColor: '#000', color: "#fffffa" };
return { backgroundColor: '#000', color: '#fffffa' };
};
/**
* @description Creates a list of classnames from array of css module conditions
* @param classNames - css modules objects
*/
export const cx = (classNames: any[]) => classNames.filter(Boolean).join(" ");
export const cx = (classNames: any[]) => classNames.filter(Boolean).join(' ');
@@ -144,7 +144,7 @@ $playback-width: 26rem;
.eventEditor {
border-radius: 8px 8px 0 0;
background-color: $bg-container-l2;
box-shadow: rgba(0, 0, 0, 0.35) 0 3px 6px 6px;
box-shadow: $large-bottom-drawer-shadow;
border-top: 1px solid $white-20;
position: absolute;
bottom: 0;
@@ -164,7 +164,7 @@ $playback-width: 26rem;
}
.header {
background-color: $gray-1200;
background-color: $gray-1250;
padding: 0.5rem;
border-left: 1px solid $white-10;
border-radius: 0 8px 0 0;
@@ -4,29 +4,26 @@
.operatorContainer {
width: 100vw;
height: 100vh;
font-size: 0.8rem;
display: flex;
flex-direction: column;
gap: 2px;
color: white;
color: $ui-white;
}
.operatorEvents {
flex: 1;
width: 100vw;
height: 100vh;
overflow: auto;
}
@mixin event-block() {
height: 40px;
padding: 0.5rem;
display: flex;
gap: 0.75rem;
align-items: center;
margin: 0.1rem;
flex-direction: column;
gap: 2px;
}
@mixin block() {
width: 100%;
padding: 0.25rem 0.5rem;
.block {
@include event-block();
background-color: $gray-1350;
}
// tablet
@media (min-width: $min-tablet) {
padding: 0.25rem 1rem;
}
}
+31 -26
View File
@@ -1,27 +1,36 @@
import { UIEvent, useState } from 'react';
import { UIEvent, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { SupportedEvent, UserFields } from 'ontime-types';
import { getLastEvent } from 'ontime-utils';
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
import { OPERATOR_OPTIONS } from '../../common/components/view-params-editor/constants';
import Empty from '../../common/components/state/Empty';
import { getOperatorOptions } from '../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import { useOperator } from '../../common/hooks/useSocket';
import useRundown from '../../common/hooks-query/useRundown';
import useUserFields from '../../common/hooks-query/useUserFields';
import FocusBlock from './focus-block/focus-block';
import OpBlock from './op-block/OpBlock';
import OpEvent from './op-event/OpEvent';
import TimeBlock from './time-block/TimeBlock';
import { getRundownEnd } from './operator.utils';
import FollowButton from './follow-button/FollowButton';
import OperatorBlock from './operator-block/OperatorBlock';
import OperatorEvent from './operator-event/OperatorEvent';
import StatusBar from './status-bar/StatusBar';
import style from './Operator.module.scss';
import { isStringBoolean } from '../../common/utils/viewUtils';
export default function Operator() {
const { data, status } = useRundown();
const { data: userFields, status: userFieldsStatus } = useUserFields();
const featureData = useOperator();
const [showChild, setShowChild] = useState(false);
const [searchParams] = useSearchParams();
// Set window title
useEffect(() => {
document.title = 'ontime - Operator';
}, []);
const handleScroll = (event: UIEvent<HTMLElement>) => {
const scrollThreshold = 50;
const scrollPosition = (event.target as HTMLElement).scrollTop;
@@ -29,49 +38,45 @@ export default function Operator() {
setShowChild(scrollPosition > scrollThreshold);
};
if (!data || status === 'loading') {
return <>loading</>;
if (!data || status === 'loading' || !userFields || userFieldsStatus === 'loading') {
return <Empty text='Loading...' />;
}
// get fields which the user subscribed to
const subscribe = searchParams.get('subscribe') as keyof UserFields | null;
const lastEvent = getRundownEnd(data);
let eventIndex = 0;
const showSeconds = isStringBoolean(searchParams.get('showseconds'));
const lastEvent = getLastEvent(data);
const operatorOptions = getOperatorOptions(userFields);
return (
<div className={style.operatorContainer}>
<NavigationMenu />
<ViewParamsEditor paramFields={OPERATOR_OPTIONS} />
<ViewParamsEditor paramFields={operatorOptions} />
<div className={style.operatorEvents} onScroll={handleScroll}>
{data.map((entry) => {
if (entry.type === SupportedEvent.Event) {
eventIndex += 1;
return (
<OpEvent
<OperatorEvent
key={entry.id}
index={eventIndex}
cue={entry.cue}
data={entry}
isSelected={featureData.selectedEventId === entry.id}
subscribed={subscribe || undefined}
subscribed={subscribe}
showSeconds={showSeconds}
/>
);
}
if (entry.type === SupportedEvent.Block) {
return (
// @arihanv
// we prefer moving these wrapper divs to the block
// so that the styles and all attributes can be self-contained
<div key={entry.id} className={style.block}>
<OpBlock data={entry} />
</div>
);
return <OperatorBlock key={entry.id} title={entry.title} />;
}
return null;
})}
{showChild && <FocusBlock />}
</div>
<TimeBlock playback={featureData.playback} lastEvent={lastEvent} selectedEventId={featureData.selectedEventId} />
<FollowButton isVisible={showChild} onClickHandler={() => undefined} />
<StatusBar playback={featureData.playback} lastEvent={lastEvent} selectedEventId={featureData.selectedEventId} />
</div>
);
}
@@ -1,17 +0,0 @@
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
import style from './focusBlock.module.scss';
export default function FocusBlock() {
const handleClick = () => console.log('click follow');
// @arihavn, can we find a way to have a single button here, no need for the div
return (
<div className={style.focusBlock}>
<button className={style.focusButton} onClick={handleClick}>
<IoLocate size={16} />
Follow
</button>
</div>
);
}
@@ -1,22 +0,0 @@
@use '../../../../src/theme/ontimeColours' as *;
.focusBlock {
bottom: 0;
left: 0;
right: 0;
position: sticky;
display: flex;
justify-content: center;
width: 100%;
}
.focusButton {
margin: 10px;
background-color: $blue-500;
padding: 0.25rem 1rem;
border-radius: 99px;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
}
@@ -0,0 +1,36 @@
@use '../../../../src/theme/ontimeColours' as *;
@use '../../../../src/theme/v2Styles' as *;
.followButton {
position: relative;
bottom: 17.5vh;
margin: 0 auto;
z-index: 1;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 1rem;
background-color: $blue-700;
color: #fff;
font-size: 1rem;
border-radius: 99px;
transition: bottom 1s;
&:active {
transition: background-color $transition-time-action;
background-color: $blue-900;
}
}
.hidden {
transition: bottom 1s;
bottom: -0;
}
// tablet
@media (min-width: $min-tablet) {
.followButton {
bottom: 10vh;
}
}
@@ -0,0 +1,23 @@
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
import { cx } from '../../../common/utils/styleUtils';
import style from './FollowButton.module.scss';
interface FollowButtonProps {
isVisible: boolean;
onClickHandler: () => void;
}
export default function FollowButton(props: FollowButtonProps) {
const { isVisible, onClickHandler } = props;
const classes = cx([style.followButton, !isVisible && style.hidden]);
return (
<button className={classes} onClick={onClickHandler} type='button'>
<IoLocate />
Follow
</button>
);
}
@@ -1,3 +0,0 @@
.OpBlock {
font-size: 1rem;
}
@@ -1,11 +0,0 @@
import { OntimeBlock } from 'ontime-types';
import style from './OpBlock.module.scss';
interface OpBlockProps {
data: OntimeBlock;
}
export default function OpBlock({ data }: OpBlockProps) {
return <div className={style.OpBlock}>{data.title}</div>;
}
@@ -1,66 +0,0 @@
@use '../../../../src/theme/ontimeColours' as *;
@import '../Operator.module.scss';
.scheduledEvent {
@include event-block();
background-color: $gray-1300;
}
.runningTimer {
background-color: $green-700;
}
.cue {
font-size: 0.75rem;
background-color: $gray-1050; // to override inline
padding: 0.2rem;
min-width: 4em; // we do not want these to resize
text-align: center;
border-radius: 2px;
&:after {
content: '\200b';
}
}
.title {
font-size: 1rem;
letter-spacing: 0.02px;
}
.event {
display: flex;
gap: 6px;
width: 100%;
}
.block {
width: 100%;
}
.fields {
display: block;
font-weight: 700;
color: $orange-500;
letter-spacing: 0.02px;
}
.time {
display: flex;
flex-direction: column;
font-size: 0.65rem;
text-align: right;
}
.time > div {
margin-bottom: -4px; /* Adjust the value as needed */
}
.title {
flex: 1;
}
.indicator {
display: flex;
justify-content: space-between;
}
@@ -1,55 +0,0 @@
import { OntimeEvent, UserFields } from 'ontime-types';
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
import { useTimer } from '../../../common/hooks/useSocket';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatTime } from '../../../common/utils/time';
import style from './OpEvent.module.scss';
type OpEventProps = {
data: OntimeEvent;
index: number;
isSelected: boolean;
subscribed?: keyof UserFields;
};
function RollingTime() {
const timer = useTimer();
return <>{formatTime(timer.current, { showSeconds: true, format: 'hh:mm:ss' })}</>;
}
export default function OpEvent({ data, index, isSelected, subscribed }: OpEventProps) {
const start = formatTime(data.timeStart, { format: 'hh:mm' });
const end = formatTime(data.timeEnd, { format: 'hh:mm' });
const cueColours = data.colour && getAccessibleColour(data.colour);
const subscribedData = (subscribed ? data?.[subscribed] : undefined) || '';
// @arihanv when selected, the whole row should become green
return (
<div className={`${isSelected ? style.runningTimer : undefined}`}>
<div className={style.scheduledEvent}>
<div className={style.cue} style={{ ...cueColours }}>
{index}
</div>
<div className={style.event}>
<div className={style.title}>
{data.title} - {data.subtitle}
</div>
<div className={style.time}>
<div>
{start} - {end}
</div>
<div className={style.indicator}>
<DelayIndicator delayValue={data.delay} />
{isSelected ? <RollingTime /> : formatTime(data.duration, { showSeconds: true, format: 'hh:mm:ss' })}
</div>
</div>
</div>
</div>
{/** @arihanv we likely want to animate the height of the fields div */}
<div className={style.fields}>{subscribedData}</div>
</div>
);
}
@@ -0,0 +1,8 @@
@use '../../../../src/theme/ontimeColours' as *;
@import '../Operator.module.scss';
.block {
@include block();
background-color: $gray-1350;
font-size: 1.25rem;
}
@@ -0,0 +1,9 @@
import style from './OperatorBlock.module.scss';
interface OperatorBlockProps {
title: string;
}
export default function OperatorBlock({ title }: OperatorBlockProps) {
return <div className={style.block}>{title}</div>;
}
@@ -0,0 +1,67 @@
@use '../../../../src/theme/v2Styles' as *;
@use '../../../../src/theme/ontimeColours' as *;
@import '../Operator.module.scss';
.event {
@include block();
background-color: $gray-1300;
&.subscribed {
background-color: $gray-1250;
}
&.running {
background-color: $red-700;
}
}
.titles {
display: flex;
align-items: center;
justify-content: space-between;
.title {
font-size: 1.5rem;
letter-spacing: 0.02px;
}
.cue {
font-size: 1rem;
background-color: $gray-1050; // to override inline
padding: 0 0.5rem;
text-align: center;
border-radius: $component-border-radius-sm;
}
}
.times {
display: flex;
align-items: center;
justify-content: space-between;
font-size: calc(1rem - 1px);
.runtime {
display: flex;
align-items: center;
gap: 0.25rem;
}
}
.fields {
font-size: 1.25rem;
font-weight: 500;
color: $orange-500;
letter-spacing: 0.02px;
height: fit-content;
transition-property: height;
transition-duration: $transition-time-feedback;
}
// tablet
@media (min-width: $min-tablet) {
.times {
font-size: 1rem;
}
}
@@ -0,0 +1,63 @@
import { OntimeEvent, UserFields } from 'ontime-types';
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
import { useTimer } from '../../../common/hooks/useSocket';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatTime } from '../../../common/utils/time';
import style from './OperatorEvent.module.scss';
interface OperatorEventProps {
data: OntimeEvent;
cue: string;
isSelected: boolean;
subscribed: keyof UserFields | null;
showSeconds: boolean;
}
// extract this to contain re-renders
function RollingTime() {
const timer = useTimer();
return <>{formatTime(timer.current, { showSeconds: true, format: 'hh:mm:ss' })}</>;
}
export default function OperatorEvent(props: OperatorEventProps) {
const { data, cue, isSelected, subscribed, showSeconds } = props;
const start = formatTime(data.timeStart, { showSeconds });
const end = formatTime(data.timeEnd, { showSeconds });
const cueColours = data.colour && getAccessibleColour(data.colour);
const subscribedData = (subscribed ? data?.[subscribed] : undefined) || '';
const operatorClasses = cx([
style.event,
isSelected ? style.running : null,
subscribedData ? style.subscribed : null,
]);
return (
<div className={operatorClasses}>
<div className={style.titles}>
<span className={style.title}>
{data.title} - {data.subtitle}
</span>
<span className={style.cue} style={{ ...cueColours }}>
{cue}
</span>
</div>
<div className={style.times}>
<span className={style.schedule}>
{start} - {end}
</span>
<span className={style.runtime}>
<DelayIndicator delayValue={data.delay} />
{isSelected ? <RollingTime /> : formatTime(data.duration, { showSeconds: true, format: 'hh:mm:ss' })}
</span>
</div>
<div className={style.fields}>{subscribedData}</div>
</div>
);
}
@@ -1,15 +0,0 @@
import { OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types';
export function getRundownEnd(rundown: OntimeRundown): OntimeEvent | null {
if (rundown.length < 1) {
return null;
}
for (let i = rundown.length - 1; i > 0; i--) {
if (rundown[i].type === SupportedEvent.Event) {
return rundown[i] as OntimeEvent;
}
}
return null;
}
@@ -0,0 +1,34 @@
@use '../../../../src/theme/ontimeColours' as *;
@use '../../../../src/theme/v2Styles' as *;
.statusBar {
position: absolute;
bottom: 0;
width: 100%;
background-color: $gray-1350;
z-index: 2;
padding: 0.5rem;
display: flex;
justify-content: space-between;
border-top: 1px solid $white-10;
box-shadow: $large-bottom-drawer-shadow;
}
.column {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.clock {
display: flex;
gap: 1.5rem;
margin-right: 1rem;
}
.label {
font-size: 0.75rem;
color: gray;
}
@@ -1,12 +1,13 @@
import { OntimeEvent, Playback } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
import { useTimer } from '../../../common/hooks/useSocket';
import { formatTime } from '../../../common/utils/time';
import styles from './TimeBlock.module.scss';
import styles from './StatusBar.module.scss';
export default function TimeBlock({
export default function StatusBar({
playback,
lastEvent,
selectedEventId,
@@ -23,27 +24,19 @@ export default function TimeBlock({
}
const timeEnd = lastEvent.id === selectedEventId ? timer.expectedFinish : lastEvent.timeEnd;
return formatTime(timeEnd, { showSeconds: true, format: 'hh:mm:ss' });
return millisToString(timeEnd);
};
// TODO: format should be user defined
// use user defined format
const timeNow = formatTime(timer.clock, {
showSeconds: true,
format: 'hh:mm:ss',
});
const runningTime = formatTime(timer.current, {
showSeconds: true,
format: 'hh:mm:ss',
});
const elapsedTime = formatTime(timer.elapsed, {
showSeconds: true,
format: 'hh:mm:ss',
});
const runningTime = millisToString(timer.current);
const elapsedTime = millisToString(timer.elapsed);
return (
<div className={styles.TimeBlock}>
<div className={styles.statusBar}>
<PlaybackIcon state={playback} />
<div className={styles.clock}>
<div className={styles.column}>
@@ -1,25 +0,0 @@
.TimeBlock {
padding: 0.65rem;
display: flex;
justify-content: space-between;
border-top: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: rgba(0, 0, 0, 0.35) 0 3px 6px 6px
}
.column {
display: flex;
flex-direction: column;
align-items: center;
}
.clock {
display: flex;
gap: 1.5rem;
margin-right: 1rem;
}
.label {
font-size: 0.75rem;
color: gray;
}
@@ -7,7 +7,7 @@ $block-binder-width: 2rem;
$block-clearance: 0.5rem;
$block-border-radius: 0.5rem;
$block-text-color: $gray-50;
$block-bg: $gray-1200;
$block-bg: $gray-1250;
$block-bg2: $gray-1050; // for delay and blocks
$block-box-shadow: rgba(0, 0, 0, 0.5) 0 0 3px 2px;
$secondary-block-height: 2.5rem;
+2 -2
View File
@@ -24,8 +24,8 @@ $gray-900: #4c4c4c;
$gray-1000: #404040;
$gray-1050: #303030;
$gray-1100: #2d2d2d;
$gray-1250: #262626;
$gray-1200: #202020;
$gray-1200: #262626;
$gray-1250: #202020;
$gray-1300: #1a1a1a;
$gray-1350: #101010;
$pure-white: #fff;
+5 -1
View File
@@ -36,6 +36,7 @@ $bg-container-onlight: $gray-100;
$box-shadow-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
$box-shadow-l2: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
$large-bottom-drawer-shadow: rgba(0, 0, 0, 0.35) 0 3px 6px 6px;
$modal-note-color: $gray-700;
@@ -54,6 +55,9 @@ $section-white: $ui-white;
$inner-section-text-size: calc(1rem - 2px);
$text-body-size: calc(1rem - 1px);
// media queries
$min-tablet: 500px;
.blink {
animation: blink $blinking-time linear infinite;
}
@@ -62,4 +66,4 @@ $text-body-size: calc(1rem - 1px);
50% {
opacity: 20%;
}
}
}
+2 -2
View File
@@ -27,7 +27,7 @@ export const ontimeButtonOutlined = {
},
_active: {
backgroundColor: '#2d2d2d', // $gray-1100
borderColor: '#202020', // $gray-12000
borderColor: '#202020', // $gray-1250
},
};
@@ -43,7 +43,7 @@ export const ontimeButtonSubtle = {
},
_active: {
backgroundColor: '#2d2d2d', // $gray-1100
borderColor: '#202020', // $gray-12000
borderColor: '#202020', // $gray-1250
},
};
+3 -3
View File
@@ -1,9 +1,9 @@
export const ontimeBlockRadio = {
control: {
borderColor: '#262626', // $gray-1250
backgroundColor: '#262626', // $gray-1250
borderColor: '#262626', // $gray-1200
backgroundColor: '#262626', // $gray-1200
_checked: {
borderColor: '#262626', // $gray-1250
borderColor: '#262626', // $gray-1200
color: '#3182ce', // $action-blue
backgroundColor: '#3182ce', // $action-blue
},
+1 -1
View File
@@ -1,6 +1,6 @@
const commonStyles = {
fontWeight: '400',
backgroundColor: '#262626', // $gray-1250
backgroundColor: '#262626', // $gray-1200
color: '#e2e2e2', // $gray-200
border: '1px solid transparent',
_hover: {
+1 -1
View File
@@ -1,5 +1,5 @@
// runtime utils
export { getFirst, getNext, getPrevious } from './src/rundown-utils/rundownUtils.js';
export { getFirst, getLastEvent, getNext, getPrevious } from './src/rundown-utils/rundownUtils.js';
export { validatePlayback } from './src/validate-action/validatePlayback.js';
// rundown utils
@@ -26,6 +26,21 @@ export function getFirstEvent(rundown: OntimeRundownEntry[]) {
return null;
}
export function getLastEvent(rundown: OntimeRundown): OntimeEvent | null {
if (rundown.length < 1) {
return null;
}
for (let i = rundown.length - 1; i > 0; i--) {
const event = rundown.at(i);
if (isOntimeEvent(event)) {
return event;
}
}
return null;
}
/**
* Gets next event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown