mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-08 08:53:51 +00:00
refactor: gather group metadata
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
"@emotion/react": "^11.10.6",
|
||||
"@emotion/styled": "^11.10.6",
|
||||
"@fontsource/open-sans": "^5.0.28",
|
||||
"@mantine/hooks": "^7.13.3",
|
||||
"@mantine/hooks": "^7.17.2",
|
||||
"@sentry/react": "^8.43.0",
|
||||
"@table-nav/react": "^0.0.7",
|
||||
"@tanstack/react-query": "^5.62.7",
|
||||
|
||||
@@ -97,9 +97,7 @@ export const useEventAction = () => {
|
||||
linkPrevious: options?.linkPrevious ?? linkPrevious,
|
||||
};
|
||||
|
||||
if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) {
|
||||
newEvent.linkStart = applicationOptions.lastEventId;
|
||||
} else if (applicationOptions?.lastEventId) {
|
||||
if (applicationOptions?.lastEventId) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
|
||||
const rundownData = queryClient.getQueryData<Rundown>(RUNDOWN)!;
|
||||
const previousEvent = rundownData.entries[applicationOptions.lastEventId];
|
||||
@@ -109,9 +107,8 @@ export const useEventAction = () => {
|
||||
}
|
||||
|
||||
// Override event with options from editor settings
|
||||
if (applicationOptions.defaultPublic) {
|
||||
newEvent.isPublic = true;
|
||||
}
|
||||
newEvent.linkStart = applicationOptions.linkPrevious;
|
||||
newEvent.isPublic = applicationOptions.defaultPublic;
|
||||
|
||||
if (newEvent.duration === undefined && newEvent.timeEnd === undefined) {
|
||||
newEvent.duration = parseUserTime(defaultDuration);
|
||||
@@ -263,7 +260,7 @@ export const useEventAction = () => {
|
||||
newEvent.duration = value === '' ? undefined : calculateNewValue();
|
||||
} else if (field === 'timeStart') {
|
||||
// an empty values means we should link to the previous
|
||||
newEvent.linkStart = value === '' ? 'true' : null;
|
||||
newEvent.linkStart = value === '';
|
||||
newEvent.timeStart = value === '' ? undefined : calculateNewValue();
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -15,7 +15,8 @@ describe('cloneEvent()', () => {
|
||||
timeEnd: 10,
|
||||
timerType: TimerType.CountDown,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
linkStart: null,
|
||||
currentBlock: 'test',
|
||||
linkStart: false,
|
||||
countToEnd: false,
|
||||
endAction: EndAction.None,
|
||||
isPublic: false,
|
||||
@@ -45,6 +46,7 @@ describe('cloneEvent()', () => {
|
||||
timeEnd: original.timeEnd,
|
||||
timerType: original.timerType,
|
||||
timeStrategy: original.timeStrategy,
|
||||
currentBlock: 'test',
|
||||
countToEnd: original.countToEnd,
|
||||
linkStart: original.linkStart,
|
||||
endAction: original.endAction,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { IoAdd } from 'react-icons/io5';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
|
||||
import style from './Empty.module.scss';
|
||||
|
||||
interface BlockEmptyProps {
|
||||
handleAddNew: () => void;
|
||||
}
|
||||
|
||||
export default function BlockEmpty(props: BlockEmptyProps) {
|
||||
const { handleAddNew } = props;
|
||||
|
||||
return (
|
||||
<div className={style.empty}>
|
||||
<Button size='sm' onClick={handleAddNew} variant='ontime-filled' leftIcon={<IoAdd />}>
|
||||
Create Event
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.empty {
|
||||
padding-block: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -23,15 +23,6 @@
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.alignCenter {
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
|
||||
.spaceTop {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.spacer {
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
@@ -5,23 +5,19 @@ import { useHotkeys } from '@mantine/hooks';
|
||||
import {
|
||||
type EntryId,
|
||||
type MaybeString,
|
||||
type PlayableEvent,
|
||||
type Rundown,
|
||||
isOntimeBlock,
|
||||
isOntimeEvent,
|
||||
isPlayableEvent,
|
||||
Playback,
|
||||
SupportedEvent,
|
||||
} from 'ontime-types';
|
||||
import {
|
||||
checkIsNextDay,
|
||||
getFirstNormal,
|
||||
getLastNormal,
|
||||
getNextBlockNormal,
|
||||
getNextNormal,
|
||||
getPreviousBlockNormal,
|
||||
getPreviousNormal,
|
||||
isNewLatest,
|
||||
reorderArray,
|
||||
} from 'ontime-utils';
|
||||
|
||||
@@ -32,7 +28,10 @@ import { AppMode, useAppMode } from '../../common/stores/appModeStore';
|
||||
import { useEntryCopy } from '../../common/stores/entryCopyStore';
|
||||
import { cloneEvent } from '../../common/utils/eventsManager';
|
||||
|
||||
import BlockBlock from './block-block/BlockBlock';
|
||||
import QuickAddBlock from './quick-add-block/QuickAddBlock';
|
||||
import BlockEmpty from './BlockEmpty';
|
||||
import { makeRundownMetadata } from './rundown.utils';
|
||||
import RundownEmpty from './RundownEmpty';
|
||||
import { useEventSelection } from './useEventSelection';
|
||||
|
||||
@@ -264,20 +263,11 @@ export default function Rundown({ data }: RundownProps) {
|
||||
return <RundownEmpty handleAddNew={() => insertAtId(SupportedEvent.Event, cursor)} />;
|
||||
}
|
||||
|
||||
// last event is used to calculate relative timings
|
||||
let lastEvent: PlayableEvent | null = null; // used by indicators
|
||||
let thisEvent: PlayableEvent | null = null;
|
||||
// previous entry is used to infer position in the rundown for new events
|
||||
let previousEntryId: MaybeString = null;
|
||||
let thisId: MaybeString = null;
|
||||
|
||||
let eventIndex = 0;
|
||||
// all events before the current selected are in the past
|
||||
let isPast = Boolean(featureData?.selectedEventId);
|
||||
let isNextDay = false;
|
||||
let totalGap = 0;
|
||||
// 1. gather presentation options
|
||||
const isEditMode = appMode === AppMode.Edit;
|
||||
let isLinkedToLoaded = true; //check if the event can link all the way back to the currently playing event
|
||||
|
||||
// 2. initialise rundown metadata
|
||||
const process = makeRundownMetadata(featureData?.selectedEventId);
|
||||
|
||||
return (
|
||||
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
|
||||
@@ -292,64 +282,96 @@ export default function Rundown({ data }: RundownProps) {
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
if (index === 0) {
|
||||
eventIndex = 0;
|
||||
}
|
||||
isNextDay = false;
|
||||
previousEntryId = thisId;
|
||||
thisId = entryId;
|
||||
if (isOntimeEvent(entry)) {
|
||||
// event indexes are 1 based in frontend
|
||||
eventIndex++;
|
||||
lastEvent = thisEvent;
|
||||
|
||||
if (isPlayableEvent(entry)) {
|
||||
isNextDay = checkIsNextDay(entry, lastEvent);
|
||||
if (!isPast) {
|
||||
totalGap += entry.gap;
|
||||
// We also include countToEnd in this test as the behavior of a linked event coming after a countToEnd is simelar to an unlinked event
|
||||
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart !== null && !lastEvent?.countToEnd;
|
||||
}
|
||||
if (isNewLatest(entry, lastEvent)) {
|
||||
// populate previous entry
|
||||
thisEvent = entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
const rundownMeta = process(entry);
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === order.length - 1;
|
||||
const isLoaded = featureData?.selectedEventId === entry.id;
|
||||
const isNext = featureData?.nextEventId === entry.id;
|
||||
const hasCursor = entry.id === cursor;
|
||||
if (isLoaded) {
|
||||
isPast = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={entry.id}>
|
||||
{isEditMode && (hasCursor || isFirst) && <QuickAddBlock previousEventId={previousEntryId} />}
|
||||
<div className={style.entryWrapper} data-testid={`entry-${eventIndex}`}>
|
||||
{isOntimeEvent(entry) && <div className={style.entryIndex}>{eventIndex}</div>}
|
||||
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
|
||||
<RundownEntry
|
||||
type={entry.type}
|
||||
isPast={isPast}
|
||||
eventIndex={eventIndex}
|
||||
data={entry}
|
||||
loaded={isLoaded}
|
||||
hasCursor={hasCursor}
|
||||
isNext={isNext}
|
||||
previousEntryId={previousEntryId}
|
||||
previousEventId={lastEvent?.id}
|
||||
playback={isLoaded ? featureData.playback : undefined}
|
||||
isRolling={featureData.playback === Playback.Roll}
|
||||
isNextDay={isNextDay}
|
||||
totalGap={totalGap}
|
||||
isLinkedToLoaded={isLinkedToLoaded}
|
||||
/>
|
||||
{isEditMode && (hasCursor || isFirst) && (
|
||||
<QuickAddBlock showBlocks previousEventId={rundownMeta.previousEntryId} />
|
||||
)}
|
||||
{isOntimeBlock(entry) ? (
|
||||
<BlockBlock data={entry} hasCursor={hasCursor}>
|
||||
{entry.events.length === 0 && (
|
||||
<BlockEmpty handleAddNew={() => insertAtId(SupportedEvent.Event, cursor)} />
|
||||
)}
|
||||
{entry.events.map((eventId, nestedIndex) => {
|
||||
const nestedEntry = entries[eventId];
|
||||
const nestedRundownMeta = process(nestedEntry);
|
||||
const isFirstInGroup = nestedIndex === 0;
|
||||
const isLastInGroup = nestedIndex === entry.events.length - 1;
|
||||
const hasNestedCursor = nestedEntry.id === cursor;
|
||||
|
||||
if (!isOntimeEvent(nestedEntry)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Fragment key={nestedEntry.id}>
|
||||
{isEditMode && (hasNestedCursor || isFirstInGroup) && (
|
||||
<QuickAddBlock previousEventId={rundownMeta.previousEntryId} />
|
||||
)}
|
||||
|
||||
<div
|
||||
key={nestedEntry.id}
|
||||
className={style.entryWrapper}
|
||||
data-testid={`entry-${nestedRundownMeta.eventIndex}`}
|
||||
>
|
||||
<div className={style.entryIndex}>{nestedRundownMeta.eventIndex}</div>
|
||||
<div className={style.entry} ref={hasNestedCursor ? cursorRef : undefined}>
|
||||
<RundownEntry
|
||||
key={nestedEntry.id}
|
||||
type={nestedEntry.type}
|
||||
isPast={nestedRundownMeta.isPast}
|
||||
eventIndex={nestedRundownMeta.eventIndex}
|
||||
data={nestedEntry}
|
||||
loaded={nestedRundownMeta.isLoaded}
|
||||
hasCursor={hasNestedCursor}
|
||||
isNext={isNext}
|
||||
previousEntryId={nestedRundownMeta.previousEntryId}
|
||||
previousEventId={nestedRundownMeta.previousEvent?.id}
|
||||
playback={nestedRundownMeta.isLoaded ? featureData.playback : undefined}
|
||||
isRolling={featureData.playback === Playback.Roll}
|
||||
isNextDay={nestedRundownMeta.isNextDay}
|
||||
totalGap={nestedRundownMeta.totalGap}
|
||||
isLinkedToLoaded={nestedRundownMeta.isLinkedToLoaded}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isEditMode && (hasNestedCursor || isLastInGroup) && (
|
||||
<QuickAddBlock previousEventId={entry.id} />
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</BlockBlock>
|
||||
) : (
|
||||
<div className={style.entryWrapper} data-testid={`entry-${rundownMeta.eventIndex}`}>
|
||||
{isOntimeEvent(entry) && <div className={style.entryIndex}>{rundownMeta.eventIndex}</div>}
|
||||
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
|
||||
<RundownEntry
|
||||
type={entry.type}
|
||||
isPast={rundownMeta.isPast}
|
||||
eventIndex={rundownMeta.eventIndex}
|
||||
data={entry}
|
||||
loaded={rundownMeta.isLoaded}
|
||||
hasCursor={hasCursor}
|
||||
isNext={isNext}
|
||||
previousEntryId={rundownMeta.previousEntryId}
|
||||
previousEventId={rundownMeta.previousEvent?.id}
|
||||
playback={rundownMeta.isLoaded ? featureData.playback : undefined}
|
||||
isRolling={featureData.playback === Playback.Roll}
|
||||
isNextDay={rundownMeta.isNextDay}
|
||||
totalGap={rundownMeta.totalGap}
|
||||
isLinkedToLoaded={rundownMeta.isLinkedToLoaded}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isEditMode && (hasCursor || isLast) && <QuickAddBlock previousEventId={entry.id} />}
|
||||
)}
|
||||
{isEditMode && (hasCursor || isLast) && <QuickAddBlock showBlocks previousEventId={entry.id} />}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Button } from '@chakra-ui/react';
|
||||
|
||||
import Empty from '../../common/components/state/Empty';
|
||||
|
||||
import style from './Rundown.module.scss';
|
||||
import style from './Empty.module.scss';
|
||||
|
||||
interface RundownEmptyProps {
|
||||
handleAddNew: () => void;
|
||||
@@ -13,9 +13,9 @@ export default function RundownEmpty(props: RundownEmptyProps) {
|
||||
const { handleAddNew } = props;
|
||||
|
||||
return (
|
||||
<div className={style.alignCenter}>
|
||||
<Empty style={{ marginTop: '7vh' }} />
|
||||
<Button onClick={handleAddNew} variant='ontime-filled' className={style.spaceTop} leftIcon={<IoAdd />}>
|
||||
<div className={style.empty}>
|
||||
<Empty style={{ marginTop: '7vh', marginBottom: '1.5rem' }} />
|
||||
<Button onClick={handleAddNew} variant='ontime-filled' leftIcon={<IoAdd />}>
|
||||
Create Event
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
isOntimeBlock,
|
||||
isOntimeDelay,
|
||||
isOntimeEvent,
|
||||
MaybeString,
|
||||
@@ -15,7 +14,6 @@ import useMemoisedFn from '../../common/hooks/useMemoisedFn';
|
||||
import { useEmitLog } from '../../common/stores/logger';
|
||||
import { cloneEvent } from '../../common/utils/eventsManager';
|
||||
|
||||
import BlockBlock from './block-block/BlockBlock';
|
||||
import DelayBlock from './delay-block/DelayBlock';
|
||||
import EventBlock from './event-block/EventBlock';
|
||||
import { useEventSelection } from './useEventSelection';
|
||||
@@ -193,14 +191,6 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
actionHandler={actionHandler}
|
||||
/>
|
||||
);
|
||||
} else if (isOntimeBlock(data)) {
|
||||
return (
|
||||
<BlockBlock data={data} hasCursor={hasCursor}>
|
||||
{data.events.map((eventId) => {
|
||||
return <div key={eventId}>{eventId}</div>;
|
||||
})}
|
||||
</BlockBlock>
|
||||
);
|
||||
} else if (isOntimeDelay(data)) {
|
||||
return <DelayBlock data={data} hasCursor={hasCursor} />;
|
||||
}
|
||||
|
||||
@@ -2,19 +2,75 @@
|
||||
|
||||
.block {
|
||||
@include block-styling;
|
||||
|
||||
background-color: $block-bg2;
|
||||
overflow: hidden;
|
||||
|
||||
min-width: 34rem;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 2rem 1fr auto;
|
||||
grid-template-areas:
|
||||
'binder header'
|
||||
'content content'
|
||||
'footer footer';
|
||||
align-items: center;
|
||||
height: $secondary-block-height;
|
||||
gap: 0.5rem;
|
||||
|
||||
&.hasCursor {
|
||||
outline: 1px solid $block-cursor-color;
|
||||
}
|
||||
|
||||
.binder {
|
||||
grid-area: binder;
|
||||
height: 100%;
|
||||
background-color: $gray-1050; // to override inline
|
||||
color: $section-white;
|
||||
font-size: 1rem;
|
||||
display: grid;
|
||||
justify-content: center;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
grid-area: header;
|
||||
padding-inline: 0.5rem;
|
||||
background-color: $block-bg2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.metaRow {
|
||||
display: flex;
|
||||
gap: 3rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.metaEntry {
|
||||
font-size: calc(1rem - 3px);
|
||||
width: 4.5em;
|
||||
|
||||
:first-child {
|
||||
color: $label-gray;
|
||||
}
|
||||
}
|
||||
|
||||
.group {
|
||||
background-color: color-mix(in srgb, var(--user-bg, $gray-1050) 10%, transparent 90%);
|
||||
grid-area: content;
|
||||
padding-right: 2px;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.footer {
|
||||
grid-area: footer;
|
||||
background-color: var(--user-bg, $gray-1050) ;
|
||||
height: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.drag {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { PropsWithChildren, useRef } from 'react';
|
||||
import { IoReorderTwo } from 'react-icons/io5';
|
||||
import { IoChevronDown, IoChevronUp, IoReorderTwo } from 'react-icons/io5';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { useSessionStorage } from '@mantine/hooks';
|
||||
import { OntimeBlock } from 'ontime-types';
|
||||
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { formatDuration, formatTime } from '../../../common/utils/time';
|
||||
import EditableBlockTitle from '../common/EditableBlockTitle';
|
||||
|
||||
import style from './BlockBlock.module.scss';
|
||||
@@ -16,7 +18,7 @@ interface BlockBlockProps {
|
||||
|
||||
export default function BlockBlock(props: PropsWithChildren<BlockBlockProps>) {
|
||||
const { data, hasCursor, children } = props;
|
||||
|
||||
const [collapsed, setCollapsed] = useSessionStorage<boolean>({ key: `block-${data.id}`, defaultValue: false });
|
||||
const handleRef = useRef<null | HTMLSpanElement>(null);
|
||||
|
||||
const {
|
||||
@@ -35,16 +37,54 @@ export default function BlockBlock(props: PropsWithChildren<BlockBlockProps>) {
|
||||
transition,
|
||||
};
|
||||
|
||||
const blockClasses = cx([style.block, hasCursor ? style.hasCursor : null]);
|
||||
const binderColours = data.colour && getAccessibleColour(data.colour);
|
||||
|
||||
return (
|
||||
<div className={blockClasses} ref={setNodeRef} style={dragStyle}>
|
||||
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
|
||||
<IoReorderTwo />
|
||||
</span>
|
||||
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' />
|
||||
<button>+++</button>
|
||||
<div>{children}</div>
|
||||
<div
|
||||
className={cx([style.block, hasCursor && style.hasCursor])}
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
...(binderColours ? { '--user-bg': binderColours.backgroundColor } : {}),
|
||||
...dragStyle,
|
||||
}}
|
||||
>
|
||||
<div className={style.binder} style={{ ...binderColours }} tabIndex={-1}>
|
||||
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
|
||||
<IoReorderTwo />
|
||||
</span>
|
||||
</div>
|
||||
<div className={style.header}>
|
||||
<div className={style.titleRow}>
|
||||
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' />
|
||||
<button onClick={() => setCollapsed((prev) => !prev)}>
|
||||
{collapsed ? <IoChevronUp /> : <IoChevronDown />}
|
||||
</button>
|
||||
</div>
|
||||
<div className={style.metaRow}>
|
||||
<div className={style.metaEntry}>
|
||||
<div>Start</div>
|
||||
<div>{formatTime(data.startTime)}</div>
|
||||
</div>
|
||||
<div className={style.metaEntry}>
|
||||
<div>End</div>
|
||||
<div>{formatTime(data.endTime)}</div>
|
||||
</div>
|
||||
<div className={style.metaEntry}>
|
||||
<div>Duration</div>
|
||||
<div>{formatDuration(data.duration)}</div>
|
||||
</div>
|
||||
<div className={style.metaEntry}>
|
||||
<div>Events</div>
|
||||
<div>{data.numEvents}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className={style.group} style={binderColours ? { '--user-bg': binderColours.backgroundColor } : {}}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
<div className={style.footer} style={binderColours ? { '--user-bg': binderColours.backgroundColor } : {}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from 'react-icons/io5';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { EndAction, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||
import { EndAction, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||
|
||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
@@ -32,7 +32,7 @@ interface EventBlockProps {
|
||||
timeEnd: number;
|
||||
duration: number;
|
||||
timeStrategy: TimeStrategy;
|
||||
linkStart: MaybeString;
|
||||
linkStart: boolean;
|
||||
countToEnd: boolean;
|
||||
eventIndex: number;
|
||||
isPublic: boolean;
|
||||
@@ -151,7 +151,7 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
onClick: () =>
|
||||
actionHandler('update', {
|
||||
field: 'linkStart',
|
||||
value: linkStart ? null : 'true',
|
||||
value: linkStart,
|
||||
}),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
IoTime,
|
||||
} from 'react-icons/io5';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { EndAction, MaybeString, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||
import { EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||
@@ -30,7 +30,7 @@ interface EventBlockInnerProps {
|
||||
timeEnd: number;
|
||||
duration: number;
|
||||
timeStrategy: TimeStrategy;
|
||||
linkStart: MaybeString;
|
||||
linkStart: boolean;
|
||||
countToEnd: boolean;
|
||||
eventIndex: number;
|
||||
isPublic: boolean;
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function RundownEventEditor() {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedEventId = data.order.find((entryId) => selectedEvents.has(entryId));
|
||||
const selectedEventId = Array.from(selectedEvents).at(0);
|
||||
if (!selectedEventId) {
|
||||
setEvent(null);
|
||||
return;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memo } from 'react';
|
||||
import { IoInformationCircle } from 'react-icons/io5';
|
||||
import { Select, Switch, Tooltip } from '@chakra-ui/react';
|
||||
import { EndAction, MaybeString, TimerType, TimeStrategy } from 'ontime-types';
|
||||
import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
|
||||
import { millisToString, parseUserTime } from 'ontime-utils';
|
||||
|
||||
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
||||
@@ -18,7 +18,7 @@ interface EventEditorTimesProps {
|
||||
timeEnd: number;
|
||||
duration: number;
|
||||
timeStrategy: TimeStrategy;
|
||||
linkStart: MaybeString;
|
||||
linkStart: boolean;
|
||||
countToEnd: boolean;
|
||||
delay: number;
|
||||
isPublic: boolean;
|
||||
|
||||
@@ -10,12 +10,13 @@ import style from './QuickAddBlock.module.scss';
|
||||
|
||||
interface QuickAddBlockProps {
|
||||
previousEventId: MaybeString;
|
||||
showBlocks?: boolean;
|
||||
}
|
||||
|
||||
export default memo(QuickAddBlock);
|
||||
|
||||
function QuickAddBlock(props: QuickAddBlockProps) {
|
||||
const { previousEventId } = props;
|
||||
const { previousEventId, showBlocks } = props;
|
||||
const { addEvent } = useEventAction();
|
||||
const { emitError } = useEmitLog();
|
||||
|
||||
@@ -86,16 +87,18 @@ function QuickAddBlock(props: QuickAddBlockProps) {
|
||||
>
|
||||
Delay
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleCreateEvent(SupportedEvent.Block)}
|
||||
size='xs'
|
||||
variant='ontime-subtle-white'
|
||||
className={style.quickBtn}
|
||||
leftIcon={<IoAdd />}
|
||||
color='#b1b1b1' // $gray-400
|
||||
>
|
||||
Block
|
||||
</Button>
|
||||
{showBlocks && (
|
||||
<Button
|
||||
onClick={() => handleCreateEvent(SupportedEvent.Block)}
|
||||
size='xs'
|
||||
variant='ontime-subtle-white'
|
||||
className={style.quickBtn}
|
||||
leftIcon={<IoAdd />}
|
||||
color='#b1b1b1' // $gray-400
|
||||
>
|
||||
Block
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { isOntimeEvent, isPlayableEvent, MaybeString, OntimeEntry, PlayableEvent } from 'ontime-types';
|
||||
import { checkIsNextDay, isNewLatest } from 'ontime-utils';
|
||||
|
||||
type RundownMetadata = {
|
||||
previousEvent: PlayableEvent | null; // The playableEvent from the previous iteration, used by indicators
|
||||
latestEvent: PlayableEvent | null; // The playableEvent most forwards in time processed so far
|
||||
previousEntryId: MaybeString; // previous entry is used to infer position in the rundown for new events
|
||||
thisId: MaybeString;
|
||||
eventIndex: number;
|
||||
isPast: boolean;
|
||||
isNext: boolean;
|
||||
isNextDay: boolean;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean; // check if the event can link all the way back to the currently playing event
|
||||
isLoaded: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a process function which aggregates the rundown metadata and event metadata
|
||||
*/
|
||||
export function makeRundownMetadata(selectedEventId: MaybeString) {
|
||||
let rundownMeta: RundownMetadata = {
|
||||
previousEvent: null,
|
||||
latestEvent: null,
|
||||
previousEntryId: null,
|
||||
thisId: null,
|
||||
eventIndex: 0,
|
||||
isPast: Boolean(selectedEventId), // all events before the current selected are in the past
|
||||
isNext: false,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: true,
|
||||
isLoaded: false,
|
||||
};
|
||||
|
||||
function process(entry: OntimeEntry): Readonly<RundownMetadata> {
|
||||
const processedRundownMetadata = processEntry(rundownMeta, selectedEventId, entry);
|
||||
rundownMeta = processedRundownMetadata;
|
||||
return rundownMeta;
|
||||
}
|
||||
|
||||
return process;
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives a rundown entry and processes its place in the rundown
|
||||
*
|
||||
*/
|
||||
function processEntry(
|
||||
rundownMetadata: RundownMetadata,
|
||||
selectedEventId: MaybeString,
|
||||
entry: Readonly<OntimeEntry>,
|
||||
): Readonly<RundownMetadata> {
|
||||
const processedData = { ...rundownMetadata };
|
||||
processedData.isNextDay = false;
|
||||
processedData.isLoaded = false;
|
||||
processedData.previousEntryId = processedData.thisId;
|
||||
processedData.thisId = entry.id;
|
||||
|
||||
if (entry.id === selectedEventId) {
|
||||
processedData.isLoaded = true;
|
||||
processedData.isPast = false;
|
||||
}
|
||||
|
||||
if (isOntimeEvent(entry)) {
|
||||
// event indexes are 1 based in UI
|
||||
processedData.eventIndex += 1;
|
||||
processedData.previousEvent = processedData.latestEvent;
|
||||
|
||||
if (isPlayableEvent(entry)) {
|
||||
processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent);
|
||||
|
||||
if (!processedData.isPast) {
|
||||
processedData.totalGap += entry.gap;
|
||||
/**
|
||||
* isLinkToLoaded is a chain value that we maintain until we find an unlinked event
|
||||
* or we find a countToEnd event
|
||||
*/
|
||||
processedData.isLinkedToLoaded =
|
||||
processedData.isLinkedToLoaded && entry.linkStart && !processedData.previousEvent?.countToEnd;
|
||||
}
|
||||
|
||||
if (isNewLatest(entry, processedData.previousEvent)) {
|
||||
// this event is the forward most event in rundown, for next iteration
|
||||
processedData.latestEvent = entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return processedData;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memo } from 'react';
|
||||
import { IoAlertCircleOutline, IoLink, IoLockClosed, IoLockOpenOutline, IoUnlink } from 'react-icons/io5';
|
||||
import { InputRightElement, Tooltip } from '@chakra-ui/react';
|
||||
import { MaybeString, TimeField, TimeStrategy } from 'ontime-types';
|
||||
import { TimeField, TimeStrategy } from 'ontime-types';
|
||||
import { dayInMs } from 'ontime-utils';
|
||||
|
||||
import TimeInputWithButton from '../../../common/components/input/time-input/TimeInputWithButton';
|
||||
@@ -19,7 +19,7 @@ interface EventBlockTimerProps {
|
||||
timeEnd: number;
|
||||
duration: number;
|
||||
timeStrategy: TimeStrategy;
|
||||
linkStart: MaybeString;
|
||||
linkStart: boolean;
|
||||
delay: number;
|
||||
showLabels?: boolean;
|
||||
}
|
||||
@@ -38,7 +38,7 @@ function TimeInputFlow(props: EventBlockTimerProps) {
|
||||
};
|
||||
|
||||
const handleLink = (doLink: boolean) => {
|
||||
updateEvent({ id: eventId, linkStart: doLink ? 'true' : null });
|
||||
updateEvent({ id: eventId, linkStart: doLink });
|
||||
};
|
||||
|
||||
const warnings = [];
|
||||
@@ -55,9 +55,9 @@ function TimeInputFlow(props: EventBlockTimerProps) {
|
||||
const isLockedEnd = timeStrategy === TimeStrategy.LockEnd;
|
||||
const isLockedDuration = timeStrategy === TimeStrategy.LockDuration;
|
||||
|
||||
const activeStart = cx([style.timeAction, linkStart ? style.active : null]);
|
||||
const activeEnd = cx([style.timeAction, isLockedEnd ? style.active : null]);
|
||||
const activeDuration = cx([style.timeAction, isLockedDuration ? style.active : null]);
|
||||
const activeStart = cx([style.timeAction, linkStart && style.active]);
|
||||
const activeEnd = cx([style.timeAction, isLockedEnd && style.active]);
|
||||
const activeDuration = cx([style.timeAction, isLockedDuration && style.active]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -69,7 +69,7 @@ function TimeInputFlow(props: EventBlockTimerProps) {
|
||||
time={timeStart}
|
||||
hasDelay={hasDelay}
|
||||
placeholder='Start'
|
||||
disabled={Boolean(linkStart)}
|
||||
disabled={linkStart}
|
||||
>
|
||||
<Tooltip label='Link start to previous end' openDelay={tooltipDelayMid}>
|
||||
<InputRightElement className={activeStart} onClick={() => handleLink(!linkStart)}>
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ function MakeStart({ getValue, row, table }: CellContext<OntimeEntry, unknown>)
|
||||
const update = (newValue: string) => handleUpdateTimer(row.original.id, 'timeStart', newValue);
|
||||
|
||||
const startTime = getValue() as number;
|
||||
const isStartLocked = (row.original as OntimeEvent).linkStart === null;
|
||||
const isStartLocked = !(row.original as OntimeEvent).linkStart;
|
||||
const delayValue = (row.original as OntimeEvent)?.delay ?? 0;
|
||||
|
||||
const displayTime = showDelayedTimes ? startTime + delayValue : startTime;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
getEventWithId,
|
||||
getFirstEvent,
|
||||
getNextEvent,
|
||||
getTimeFromPrevious,
|
||||
getTimeFrom,
|
||||
isNewLatest,
|
||||
MILLIS_PER_HOUR,
|
||||
} from 'ontime-utils';
|
||||
@@ -134,7 +134,7 @@ export function useScopedRundown(rundown: OntimeEntry[], selectedEventId: MaybeS
|
||||
firstStart = currentEntry.timeStart;
|
||||
}
|
||||
|
||||
const timeFromPrevious: number = getTimeFromPrevious(currentEntry, lastEntry);
|
||||
const timeFromPrevious: number = getTimeFrom(currentEntry, lastEntry);
|
||||
|
||||
if (timeFromPrevious === 0) {
|
||||
totalDuration += currentEntry.duration;
|
||||
|
||||
@@ -14,7 +14,7 @@ import { isEmptyObject } from '../utils/parserUtils.js';
|
||||
import { parseProperty, updateEvent } from './integration.utils.js';
|
||||
import { socket } from '../adapters/WebsocketAdapter.js';
|
||||
import { throttle } from '../utils/throttle.js';
|
||||
import { willCauseRegeneration } from '../services/rundown-service/rundownCacheUtils.js';
|
||||
import { willCauseRegeneration } from '../services/rundown-service/rundownCache.utils.js';
|
||||
import { coerceEnum } from '../utils/coerceType.js';
|
||||
|
||||
const throttledUpdateEvent = throttle(updateEvent, 20);
|
||||
|
||||
@@ -2,15 +2,11 @@ import { DatabaseModel, EndAction, SupportedEvent, TimeStrategy, TimerType } fro
|
||||
|
||||
export const demoDb: DatabaseModel = {
|
||||
rundowns: {
|
||||
demo: {
|
||||
id: 'demo',
|
||||
default: {
|
||||
id: 'default',
|
||||
title: 'Eurovision Demo',
|
||||
order: [
|
||||
'32d31',
|
||||
'21cd2',
|
||||
'0b371',
|
||||
'3cd28',
|
||||
'e457f',
|
||||
'block',
|
||||
'01e85',
|
||||
'1c420',
|
||||
'b7737',
|
||||
@@ -24,6 +20,25 @@ export const demoDb: DatabaseModel = {
|
||||
'd3eb1',
|
||||
],
|
||||
entries: {
|
||||
block: {
|
||||
type: SupportedEvent.Block,
|
||||
events: ['32d31', '21cd2', '0b371', '3cd28', 'e457f'],
|
||||
id: 'block',
|
||||
title: 'Test Block',
|
||||
note: '',
|
||||
skip: false,
|
||||
colour: 'hotpink',
|
||||
revision: 0,
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
duration: 0,
|
||||
isFirstLinked: false,
|
||||
numEvents: 0,
|
||||
custom: {
|
||||
song: 'Sekret',
|
||||
artist: 'Ronela Hajati',
|
||||
},
|
||||
},
|
||||
'32d31': {
|
||||
type: SupportedEvent.Event,
|
||||
id: '32d31',
|
||||
@@ -33,7 +48,7 @@ export const demoDb: DatabaseModel = {
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 36000000,
|
||||
timeEnd: 37200000,
|
||||
@@ -62,7 +77,7 @@ export const demoDb: DatabaseModel = {
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 37500000,
|
||||
timeEnd: 38700000,
|
||||
@@ -91,7 +106,7 @@ export const demoDb: DatabaseModel = {
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 39000000,
|
||||
timeEnd: 40200000,
|
||||
@@ -120,7 +135,7 @@ export const demoDb: DatabaseModel = {
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 40500000,
|
||||
timeEnd: 41700000,
|
||||
@@ -149,7 +164,7 @@ export const demoDb: DatabaseModel = {
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 42000000,
|
||||
timeEnd: 43200000,
|
||||
@@ -196,7 +211,7 @@ export const demoDb: DatabaseModel = {
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 47100000,
|
||||
timeEnd: 48300000,
|
||||
@@ -225,7 +240,7 @@ export const demoDb: DatabaseModel = {
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 48600000,
|
||||
timeEnd: 49800000,
|
||||
@@ -254,7 +269,7 @@ export const demoDb: DatabaseModel = {
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 50100000,
|
||||
timeEnd: 51300000,
|
||||
@@ -283,7 +298,7 @@ export const demoDb: DatabaseModel = {
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 51600000,
|
||||
timeEnd: 52800000,
|
||||
@@ -312,7 +327,7 @@ export const demoDb: DatabaseModel = {
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 53100000,
|
||||
timeEnd: 54300000,
|
||||
@@ -359,7 +374,7 @@ export const demoDb: DatabaseModel = {
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 56100000,
|
||||
timeEnd: 57300000,
|
||||
@@ -388,7 +403,7 @@ export const demoDb: DatabaseModel = {
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 57600000,
|
||||
timeEnd: 58800000,
|
||||
@@ -417,7 +432,7 @@ export const demoDb: DatabaseModel = {
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 59100000,
|
||||
timeEnd: 60300000,
|
||||
@@ -446,7 +461,7 @@ export const demoDb: DatabaseModel = {
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 60600000,
|
||||
timeEnd: 61800000,
|
||||
|
||||
@@ -15,7 +15,7 @@ export const event: Omit<OntimeEvent, 'id' | 'cue'> = {
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
timeStrategy: TimeStrategy.LockDuration,
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
countToEnd: false,
|
||||
timeStart: 0,
|
||||
timeEnd: 0,
|
||||
@@ -23,14 +23,15 @@ export const event: Omit<OntimeEvent, 'id' | 'cue'> = {
|
||||
isPublic: false,
|
||||
skip: false,
|
||||
colour: '',
|
||||
timeWarning: 120000,
|
||||
timeDanger: 60000,
|
||||
custom: {},
|
||||
// !==== RUNTIME METADATA ====! //
|
||||
currentBlock: null,
|
||||
revision: 0, // calculated at runtime
|
||||
delay: 0, // calculated at runtime
|
||||
dayOffset: 0, // calculated at runtime
|
||||
gap: 0, // calculated at runtime
|
||||
timeWarning: 120000,
|
||||
timeDanger: 60000,
|
||||
custom: {},
|
||||
};
|
||||
|
||||
export const delay: Omit<OntimeDelay, 'id'> = {
|
||||
@@ -45,11 +46,12 @@ export const block: Omit<OntimeBlock, 'id'> = {
|
||||
events: [],
|
||||
skip: false,
|
||||
colour: '',
|
||||
custom: {},
|
||||
// !==== RUNTIME METADATA ====! //
|
||||
revision: 0, // calculated at runtime
|
||||
startTime: null, // calculated at runtime
|
||||
endTime: null, // calculated at runtime
|
||||
duration: 0, // calculated at runtime
|
||||
isFirstLinked: false, // calculated at runtime
|
||||
numEvents: 0, // calculated at runtime
|
||||
custom: {},
|
||||
};
|
||||
|
||||
@@ -825,7 +825,7 @@ describe('getRuntimeOffset()', () => {
|
||||
timeEnd: 81000000,
|
||||
duration: 3600000,
|
||||
timeStrategy: 'lock-duration',
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
},
|
||||
runtime: {
|
||||
selectedEventIndex: 0,
|
||||
@@ -863,7 +863,7 @@ describe('getRuntimeOffset()', () => {
|
||||
timeEnd: 84600000,
|
||||
duration: 3600000,
|
||||
timeStrategy: 'lock-duration',
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
endAction: 'none',
|
||||
timerType: 'count-down',
|
||||
delay: 0,
|
||||
@@ -906,7 +906,7 @@ describe('getRuntimeOffset()', () => {
|
||||
timeEnd: 81000000, // 22:30:00
|
||||
duration: 3600000, // 01:00:00
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: true,
|
||||
@@ -959,7 +959,7 @@ describe('getRuntimeOffset()', () => {
|
||||
timeEnd: 81000000, // 22:30:00
|
||||
duration: 3600000, // 01:00:00
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: true,
|
||||
@@ -1010,7 +1010,7 @@ describe('getRuntimeOffset()', () => {
|
||||
timeEnd: 81000000, // 22:30:00
|
||||
duration: 3600000, // 01:00:00
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: true,
|
||||
|
||||
@@ -9,6 +9,7 @@ const baseEvent = {
|
||||
|
||||
const baseBlock = {
|
||||
type: SupportedEvent.Block,
|
||||
events: [],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,10 +12,10 @@ describe('apply()', () => {
|
||||
entries: {
|
||||
delay: makeOntimeDelay({ id: 'delay', duration: 10 }),
|
||||
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }),
|
||||
'2': makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }),
|
||||
'2': makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: true }),
|
||||
'3': makeOntimeBlock({ id: '3' }),
|
||||
'4': makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }),
|
||||
'5': makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }),
|
||||
'4': makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: false }),
|
||||
'5': makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: true }),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,10 +24,10 @@ describe('apply()', () => {
|
||||
expect(testRundown.order).toMatchObject(['1', '2', '3', '4', '5']);
|
||||
expect(testRundown.entries).toMatchObject({
|
||||
'1': { id: '1', timeStart: 10, timeEnd: 20, duration: 10, revision: 2 },
|
||||
'2': { id: '2', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '1' },
|
||||
'2': { id: '2', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: true },
|
||||
'3': { id: '3' },
|
||||
'4': { id: '4', timeStart: 30, timeEnd: 40, duration: 10, revision: 2, linkStart: null },
|
||||
'5': { id: '5', timeStart: 40, timeEnd: 50, duration: 10, revision: 2, linkStart: '4' },
|
||||
'4': { id: '4', timeStart: 30, timeEnd: 40, duration: 10, revision: 2, linkStart: false },
|
||||
'5': { id: '5', timeStart: 40, timeEnd: 50, duration: 10, revision: 2, linkStart: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,10 +38,10 @@ describe('apply()', () => {
|
||||
entries: {
|
||||
delay: makeOntimeDelay({ id: 'delay', duration: -10 }),
|
||||
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }),
|
||||
'2': makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }),
|
||||
'2': makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: true }),
|
||||
'3': makeOntimeBlock({ id: '3' }),
|
||||
'4': makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }),
|
||||
'5': makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }),
|
||||
'4': makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: false }),
|
||||
'5': makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: true }),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -50,10 +50,10 @@ describe('apply()', () => {
|
||||
expect(testRundown.order).toMatchObject(['1', '2', '3', '4', '5']);
|
||||
expect(testRundown.entries).toMatchObject({
|
||||
'1': { id: '1', timeStart: 0, timeEnd: 10, duration: 10, revision: 2 },
|
||||
'2': { id: '2', timeStart: 0, timeEnd: 10, duration: 10, revision: 2, linkStart: null },
|
||||
'2': { id: '2', timeStart: 0, timeEnd: 10, duration: 10, revision: 2, linkStart: false },
|
||||
'3': { id: '3' },
|
||||
'4': { id: '4', timeStart: 10, timeEnd: 20, duration: 10, revision: 2, linkStart: null },
|
||||
'5': { id: '5', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '4' },
|
||||
'4': { id: '4', timeStart: 10, timeEnd: 20, duration: 10, revision: 2, linkStart: false },
|
||||
'5': { id: '5', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,7 +63,7 @@ describe('apply()', () => {
|
||||
entries: {
|
||||
delay: makeOntimeDelay({ id: 'delay', duration: -50 }),
|
||||
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
|
||||
'2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, linkStart: '1' }),
|
||||
'2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, linkStart: true }),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -84,7 +84,7 @@ describe('apply()', () => {
|
||||
timeStart: 50,
|
||||
timeEnd: 100,
|
||||
duration: 50,
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
revision: 2,
|
||||
},
|
||||
});
|
||||
@@ -96,7 +96,7 @@ describe('apply()', () => {
|
||||
entries: {
|
||||
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }),
|
||||
delay: makeOntimeDelay({ id: 'delay', duration: 50 }),
|
||||
'2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }),
|
||||
'2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: true }),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -115,7 +115,7 @@ describe('apply()', () => {
|
||||
timeStart: 150,
|
||||
timeEnd: 200,
|
||||
duration: 50,
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
revision: 2,
|
||||
},
|
||||
});
|
||||
@@ -127,7 +127,7 @@ describe('apply()', () => {
|
||||
entries: {
|
||||
delay: makeOntimeDelay({ id: 'delay', duration: 50 }),
|
||||
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }),
|
||||
'2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }),
|
||||
'2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: true }),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -146,7 +146,7 @@ describe('apply()', () => {
|
||||
timeStart: 150,
|
||||
timeEnd: 200,
|
||||
duration: 50,
|
||||
linkStart: '1',
|
||||
linkStart: true,
|
||||
revision: 2,
|
||||
},
|
||||
});
|
||||
@@ -158,7 +158,7 @@ describe('apply()', () => {
|
||||
entries: {
|
||||
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }),
|
||||
delay: makeOntimeDelay({ id: 'delay', duration: -50 }),
|
||||
'2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }),
|
||||
'2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: true }),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -171,7 +171,7 @@ describe('apply()', () => {
|
||||
timeStart: 50,
|
||||
timeEnd: 100,
|
||||
duration: 50,
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
revision: 2,
|
||||
},
|
||||
});
|
||||
@@ -190,7 +190,7 @@ describe('apply()', () => {
|
||||
// gap 50
|
||||
'4': makeOntimeEvent({ id: '4', timeStart: 300, timeEnd: 350, duration: 50, gap: 50 }),
|
||||
// linked
|
||||
'5': makeOntimeEvent({ id: '5', timeStart: 350, timeEnd: 400, duration: 50, linkStart: '4' }),
|
||||
'5': makeOntimeEvent({ id: '5', timeStart: 350, timeEnd: 400, duration: 50, linkStart: true }),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -205,7 +205,7 @@ describe('apply()', () => {
|
||||
// gap (delay is 0)
|
||||
'4': { id: '4', timeStart: 300, timeEnd: 350, duration: 50, revision: 1 },
|
||||
// linked
|
||||
'5': { id: '5', timeStart: 350, timeEnd: 400, duration: 50, revision: 1, linkStart: '4' },
|
||||
'5': { id: '5', timeStart: 350, timeEnd: 400, duration: 50, revision: 1, linkStart: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -278,7 +278,7 @@ describe('apply()', () => {
|
||||
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }),
|
||||
delay: makeOntimeDelay({ id: 'delay', duration: 50 }),
|
||||
block: makeOntimeBlock({ id: 'block' }),
|
||||
'2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }),
|
||||
'2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: true }),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -299,7 +299,7 @@ describe('apply()', () => {
|
||||
timeStart: 150,
|
||||
timeEnd: 200,
|
||||
duration: 50,
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
revision: 2,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { CustomFields, OntimeEvent, SupportedEvent, TimeStrategy } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, dayInMs } from 'ontime-utils';
|
||||
|
||||
import { demoDb } from '../../../models/demoProject.js';
|
||||
|
||||
import {
|
||||
add,
|
||||
batchEdit,
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
customFieldChangelog,
|
||||
} from '../rundownCache.js';
|
||||
import { makeOntimeBlock, makeOntimeDelay, makeOntimeEvent, makeRundown } from '../__mocks__/rundown.mocks.js';
|
||||
import { ProcessedRundownMetadata } from '../rundownCache.utils.js';
|
||||
|
||||
beforeAll(() => {
|
||||
vi.mock('../../../classes/data-provider/DataProvider.js', () => {
|
||||
@@ -30,6 +33,23 @@ beforeAll(() => {
|
||||
});
|
||||
|
||||
describe('generate()', () => {
|
||||
test('benchmark function execution time', () => {
|
||||
const rundown = demoDb.rundowns.default;
|
||||
const t1 = performance.now();
|
||||
let result: ProcessedRundownMetadata | null = null;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
result = generate(rundown);
|
||||
}
|
||||
const t2 = performance.now();
|
||||
console.warn(
|
||||
'Rundown generation took',
|
||||
t2 - t1,
|
||||
'milliseconds for 100x',
|
||||
Object.keys(result?.entries ?? {}).length,
|
||||
'events',
|
||||
);
|
||||
});
|
||||
|
||||
it('creates normalised versions of a given rundown', () => {
|
||||
const rundown = makeRundown({
|
||||
order: ['1', '2', '3'],
|
||||
@@ -43,9 +63,9 @@ describe('generate()', () => {
|
||||
const initResult = generate(rundown);
|
||||
expect(initResult.order.length).toBe(3);
|
||||
expect(initResult.order).toStrictEqual(['1', '2', '3']);
|
||||
expect(initResult.rundown['1'].type).toBe(SupportedEvent.Event);
|
||||
expect(initResult.rundown['2'].type).toBe(SupportedEvent.Block);
|
||||
expect(initResult.rundown['3'].type).toBe(SupportedEvent.Delay);
|
||||
expect(initResult.entries['1'].type).toBe(SupportedEvent.Event);
|
||||
expect(initResult.entries['2'].type).toBe(SupportedEvent.Block);
|
||||
expect(initResult.entries['3'].type).toBe(SupportedEvent.Delay);
|
||||
});
|
||||
|
||||
it('calculates delays versions of a given rundown', () => {
|
||||
@@ -59,7 +79,7 @@ describe('generate()', () => {
|
||||
|
||||
const initResult = generate(rundown);
|
||||
expect(initResult.order.length).toBe(2);
|
||||
expect((initResult.rundown['2'] as OntimeEvent).delay).toBe(100);
|
||||
expect((initResult.entries['2'] as OntimeEvent).delay).toBe(100);
|
||||
expect(initResult.totalDelay).toBe(100);
|
||||
});
|
||||
|
||||
@@ -79,10 +99,10 @@ describe('generate()', () => {
|
||||
|
||||
const initResult = generate(rundown);
|
||||
expect(initResult.order.length).toBe(7);
|
||||
expect((initResult.rundown['1'] as OntimeEvent).delay).toBe(0);
|
||||
expect((initResult.rundown['2'] as OntimeEvent).delay).toBe(200);
|
||||
expect((initResult.rundown['3'] as OntimeEvent).delay).toBe(100);
|
||||
expect((initResult.rundown['4'] as OntimeEvent).delay).toBe(0);
|
||||
expect((initResult.entries['1'] as OntimeEvent).delay).toBe(0);
|
||||
expect((initResult.entries['2'] as OntimeEvent).delay).toBe(200);
|
||||
expect((initResult.entries['3'] as OntimeEvent).delay).toBe(100);
|
||||
expect((initResult.entries['4'] as OntimeEvent).delay).toBe(0);
|
||||
expect(initResult.totalDelay).toBe(0);
|
||||
expect(initResult.totalDuration).toBe(700 - 100);
|
||||
});
|
||||
@@ -167,10 +187,10 @@ describe('generate()', () => {
|
||||
|
||||
const initResult = generate(rundown);
|
||||
expect(initResult.order.length).toBe(7);
|
||||
expect((initResult.rundown['1'] as OntimeEvent).delay).toBe(0);
|
||||
expect((initResult.rundown['2'] as OntimeEvent).delay).toBe(-200);
|
||||
expect((initResult.rundown['3'] as OntimeEvent).delay).toBe(-200);
|
||||
expect((initResult.rundown['4'] as OntimeEvent).delay).toBe(-200);
|
||||
expect((initResult.entries['1'] as OntimeEvent).delay).toBe(0);
|
||||
expect((initResult.entries['2'] as OntimeEvent).delay).toBe(-200);
|
||||
expect((initResult.entries['3'] as OntimeEvent).delay).toBe(-200);
|
||||
expect((initResult.entries['4'] as OntimeEvent).delay).toBe(-200);
|
||||
expect(initResult.totalDelay).toBe(-200);
|
||||
expect(initResult.totalDuration).toBe(700 - 100);
|
||||
});
|
||||
@@ -191,7 +211,7 @@ describe('generate()', () => {
|
||||
timeStart: 11,
|
||||
duration: 1,
|
||||
timeEnd: 12,
|
||||
linkStart: '1',
|
||||
linkStart: true,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
}),
|
||||
block: makeOntimeBlock({ id: 'block' }),
|
||||
@@ -201,7 +221,7 @@ describe('generate()', () => {
|
||||
timeStart: 21,
|
||||
duration: 1,
|
||||
timeEnd: 22,
|
||||
linkStart: '2',
|
||||
linkStart: true,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
}),
|
||||
},
|
||||
@@ -209,16 +229,13 @@ describe('generate()', () => {
|
||||
|
||||
const initResult = generate(rundown);
|
||||
expect(initResult.order.length).toBe(5);
|
||||
expect((initResult.rundown['2'] as OntimeEvent).timeStart).toBe(2);
|
||||
expect((initResult.rundown['2'] as OntimeEvent).timeEnd).toBe(12);
|
||||
expect((initResult.rundown['2'] as OntimeEvent).duration).toBe(10);
|
||||
expect((initResult.entries['2'] as OntimeEvent).timeStart).toBe(2);
|
||||
expect((initResult.entries['2'] as OntimeEvent).timeEnd).toBe(12);
|
||||
expect((initResult.entries['2'] as OntimeEvent).duration).toBe(10);
|
||||
|
||||
expect((initResult.rundown['3'] as OntimeEvent).timeStart).toBe(12);
|
||||
expect((initResult.rundown['3'] as OntimeEvent).timeEnd).toBe(22);
|
||||
expect((initResult.rundown['3'] as OntimeEvent).duration).toBe(10);
|
||||
|
||||
expect(initResult.links['1']).toBe('2');
|
||||
expect(initResult.links['2']).toBe('3');
|
||||
expect((initResult.entries['3'] as OntimeEvent).timeStart).toBe(12);
|
||||
expect((initResult.entries['3'] as OntimeEvent).timeEnd).toBe(22);
|
||||
expect((initResult.entries['3'] as OntimeEvent).duration).toBe(10);
|
||||
});
|
||||
|
||||
it('links times across events, reordered', () => {
|
||||
@@ -226,16 +243,14 @@ describe('generate()', () => {
|
||||
order: ['1', '3', '2'],
|
||||
entries: {
|
||||
'1': makeOntimeEvent({ id: '1', timeStart: 1, timeEnd: 2 }),
|
||||
'3': makeOntimeEvent({ id: '3', timeStart: 21, timeEnd: 22, linkStart: '2' }),
|
||||
'2': makeOntimeEvent({ id: '2', timeStart: 11, timeEnd: 12, linkStart: '1' }),
|
||||
'3': makeOntimeEvent({ id: '3', timeStart: 21, timeEnd: 22, linkStart: true }),
|
||||
'2': makeOntimeEvent({ id: '2', timeStart: 11, timeEnd: 12, linkStart: true }),
|
||||
},
|
||||
});
|
||||
|
||||
const initResult = generate(rundown);
|
||||
expect(initResult.order.length).toBe(3);
|
||||
expect((initResult.rundown['3'] as OntimeEvent).timeStart).toBe(2);
|
||||
expect(initResult.links['1']).toBe('3');
|
||||
expect(initResult.links['3']).toBe('2');
|
||||
expect((initResult.entries['3'] as OntimeEvent).timeStart).toBe(2);
|
||||
});
|
||||
|
||||
it('calculates total duration', () => {
|
||||
@@ -333,7 +348,7 @@ describe('generate()', () => {
|
||||
timeEnd: 600000,
|
||||
duration: 600000,
|
||||
timeStrategy: TimeStrategy.LockDuration,
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
}),
|
||||
'2': makeOntimeEvent({
|
||||
id: '2',
|
||||
@@ -341,7 +356,7 @@ describe('generate()', () => {
|
||||
timeEnd: 601000,
|
||||
duration: 85801000, // <------------- value out of sync
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
linkStart: '1',
|
||||
linkStart: true,
|
||||
}),
|
||||
'3': makeOntimeEvent({
|
||||
id: '3',
|
||||
@@ -349,51 +364,37 @@ describe('generate()', () => {
|
||||
timeEnd: 602000,
|
||||
duration: 0,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
linkStart: '2',
|
||||
linkStart: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const initResult = generate(rundown);
|
||||
expect(initResult.rundown).toMatchObject({
|
||||
expect(initResult.entries).toMatchObject({
|
||||
'1': {
|
||||
timeStart: 0,
|
||||
timeEnd: 600000,
|
||||
duration: 600000,
|
||||
timeStrategy: 'lock-duration',
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
},
|
||||
'2': {
|
||||
timeStart: 600000,
|
||||
timeEnd: 601000,
|
||||
duration: 1000,
|
||||
timeStrategy: 'lock-end',
|
||||
linkStart: '1',
|
||||
linkStart: true,
|
||||
},
|
||||
'3': {
|
||||
timeStart: 601000,
|
||||
timeEnd: 602000,
|
||||
duration: 1000,
|
||||
timeStrategy: 'lock-end',
|
||||
linkStart: '2',
|
||||
linkStart: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes links if invalid', () => {
|
||||
const rundown = makeRundown({
|
||||
order: ['1'],
|
||||
entries: {
|
||||
'1': makeOntimeEvent({ id: '1', timeStart: 1, linkStart: '10' }),
|
||||
},
|
||||
});
|
||||
|
||||
const initResult = generate(rundown);
|
||||
expect(initResult.order.length).toBe(1);
|
||||
expect((initResult.rundown['1'] as OntimeEvent).timeStart).toBe(1);
|
||||
expect(Object.keys(initResult.links).length).toBe(0);
|
||||
});
|
||||
|
||||
describe('custom properties feature', () => {
|
||||
it('creates a map of custom properties', () => {
|
||||
const customProperties: CustomFields = {
|
||||
@@ -433,8 +434,8 @@ describe('generate()', () => {
|
||||
lighting: ['1', '2'],
|
||||
sound: ['2'],
|
||||
});
|
||||
expect((initResult.rundown['1'] as OntimeEvent).custom).toMatchObject({ lighting: 'event 1 lx' });
|
||||
expect((initResult.rundown['2'] as OntimeEvent).custom).toMatchObject({
|
||||
expect((initResult.entries['1'] as OntimeEvent).custom).toMatchObject({ lighting: 'event 1 lx' });
|
||||
expect((initResult.entries['2'] as OntimeEvent).custom).toMatchObject({
|
||||
lighting: 'event 2 lx',
|
||||
sound: 'event 2 sound',
|
||||
});
|
||||
@@ -442,6 +443,106 @@ describe('generate()', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('generate() v4', () => {
|
||||
describe('handle of event groups', () => {
|
||||
it('correctly parses group metadata', () => {
|
||||
const rundown = makeRundown({
|
||||
order: ['1'],
|
||||
entries: {
|
||||
'1': makeOntimeBlock({ id: '1', events: ['100', '200', '300'] }),
|
||||
'100': makeOntimeEvent({ id: '100', timeStart: 100, timeEnd: 200, duration: 100, linkStart: false }),
|
||||
'200': makeOntimeEvent({ id: '200', timeStart: 200, timeEnd: 300, duration: 100 }),
|
||||
'300': makeOntimeEvent({ id: '300', timeStart: 300, timeEnd: 400, duration: 100 }),
|
||||
},
|
||||
});
|
||||
const generatedRundown = generate(rundown);
|
||||
|
||||
expect(generatedRundown.order).toMatchObject(['1']);
|
||||
expect(generatedRundown.totalDuration).toBe(300);
|
||||
expect(generatedRundown.totalDelay).toBe(0);
|
||||
expect(generatedRundown.entries).toMatchObject({
|
||||
'1': {
|
||||
type: SupportedEvent.Block,
|
||||
events: ['100', '200', '300'],
|
||||
startTime: 100,
|
||||
endTime: 400,
|
||||
duration: 300,
|
||||
isFirstLinked: false,
|
||||
numEvents: 3,
|
||||
},
|
||||
'100': { type: SupportedEvent.Event, currentBlock: '1' },
|
||||
'200': { type: SupportedEvent.Event, currentBlock: '1' },
|
||||
'300': { type: SupportedEvent.Event, currentBlock: '1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('treats groups as invisible for gap calculations', () => {
|
||||
const rundown = makeRundown({
|
||||
order: ['0', '1', '2', '3'],
|
||||
entries: {
|
||||
'0': makeOntimeEvent({ id: '0', timeStart: 0, timeEnd: 10, duration: 10, linkStart: false }),
|
||||
'1': makeOntimeBlock({ id: '1', events: ['101', '102', '103'] }),
|
||||
'101': makeOntimeEvent({ id: '101', timeStart: 100, timeEnd: 200, duration: 100, linkStart: false }),
|
||||
'102': makeOntimeEvent({ id: '102', timeStart: 200, timeEnd: 300, duration: 100, linkStart: true }),
|
||||
'103': makeOntimeEvent({ id: '103', timeStart: 300, timeEnd: 400, duration: 100, linkStart: true }),
|
||||
'2': makeOntimeBlock({ id: '2', events: ['201', '202', '203'] }),
|
||||
'201': makeOntimeEvent({ id: '201', timeStart: 500, timeEnd: 600, duration: 100, linkStart: false }),
|
||||
'202': makeOntimeEvent({ id: '202', timeStart: 600, timeEnd: 700, duration: 100, linkStart: true }),
|
||||
'203': makeOntimeEvent({ id: '203', timeStart: 700, timeEnd: 800, duration: 100, linkStart: true }),
|
||||
'3': makeOntimeBlock({ id: '3', events: ['301', '302', '303'] }),
|
||||
'301': makeOntimeEvent({ id: '301', timeStart: 900, timeEnd: 1000, duration: 100, linkStart: false }),
|
||||
'302': makeOntimeEvent({ id: '302', timeStart: 1000, timeEnd: 1100, duration: 100, linkStart: true }),
|
||||
'303': makeOntimeEvent({ id: '303', timeStart: 1100, timeEnd: 1200, duration: 100, linkStart: true }),
|
||||
},
|
||||
});
|
||||
const generatedRundown = generate(rundown);
|
||||
|
||||
expect(generatedRundown.order).toMatchObject(['0', '1', '2', '3']);
|
||||
expect(generatedRundown.totalDuration).toBe(1200);
|
||||
expect(generatedRundown.totalDelay).toBe(0);
|
||||
expect(generatedRundown.entries).toMatchObject({
|
||||
'0': { type: SupportedEvent.Event, currentBlock: null },
|
||||
'1': {
|
||||
type: SupportedEvent.Block,
|
||||
events: ['101', '102', '103'],
|
||||
startTime: 100,
|
||||
endTime: 400,
|
||||
duration: 300,
|
||||
isFirstLinked: false,
|
||||
numEvents: 3,
|
||||
},
|
||||
'101': { currentBlock: '1', gap: 90, linkStart: false },
|
||||
'102': { currentBlock: '1' },
|
||||
'103': { currentBlock: '1' },
|
||||
'2': {
|
||||
type: SupportedEvent.Block,
|
||||
events: ['201', '202', '203'],
|
||||
startTime: 500,
|
||||
endTime: 800,
|
||||
duration: 300,
|
||||
isFirstLinked: false,
|
||||
numEvents: 3,
|
||||
},
|
||||
'201': { id: '201', timeStart: 500, timeEnd: 600, duration: 100, gap: 100, linkStart: false },
|
||||
'202': { id: '202', timeStart: 600, timeEnd: 700, duration: 100 },
|
||||
'203': { id: '203', timeStart: 700, timeEnd: 800, duration: 100 },
|
||||
'3': {
|
||||
type: SupportedEvent.Block,
|
||||
events: ['301', '302', '303'],
|
||||
startTime: 900,
|
||||
endTime: 1200,
|
||||
duration: 300,
|
||||
isFirstLinked: false,
|
||||
numEvents: 3,
|
||||
},
|
||||
'301': { id: '301', timeStart: 900, timeEnd: 1000, duration: 100, gap: 100, linkStart: false },
|
||||
'302': { id: '302', timeStart: 1000, timeEnd: 1100, duration: 100 },
|
||||
'303': { id: '303', timeStart: 1100, timeEnd: 1200, duration: 100 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('add() mutation', () => {
|
||||
test('adds an event to the rundown', () => {
|
||||
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
|
||||
@@ -605,7 +706,7 @@ describe('swap() mutation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom fields', () => {
|
||||
describe('custom fields flow', () => {
|
||||
describe('createCustomField()', () => {
|
||||
it('creates a field from given parameters', () => {
|
||||
const expected = {
|
||||
@@ -639,8 +740,7 @@ describe('custom fields', () => {
|
||||
};
|
||||
|
||||
const customField = editCustomField('Sound', { label: 'Sound', type: 'string', colour: 'green' });
|
||||
expect(customFieldChangelog).toStrictEqual(new Map());
|
||||
|
||||
expect(customFieldChangelog).toStrictEqual({});
|
||||
expect(customField).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
@@ -689,10 +789,10 @@ describe('custom fields', () => {
|
||||
vi.useFakeTimers();
|
||||
const customField = editCustomField('Video', { label: 'AV', type: 'string', colour: 'red' });
|
||||
expect(customField).toStrictEqual(expectedAfter);
|
||||
expect(customFieldChangelog).toStrictEqual(new Map([['Video', 'AV']]));
|
||||
expect(customFieldChangelog).toStrictEqual({ Video: 'AV' });
|
||||
editCustomField('AV', { label: 'Video' });
|
||||
vi.runAllTimers();
|
||||
expect(customFieldChangelog).toStrictEqual(new Map());
|
||||
expect(customFieldChangelog).toStrictEqual({});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,55 +1,13 @@
|
||||
import {
|
||||
CustomFields,
|
||||
EndAction,
|
||||
OntimeEvent,
|
||||
RundownEntries,
|
||||
SupportedEvent,
|
||||
TimeStrategy,
|
||||
TimerType,
|
||||
} from 'ontime-types';
|
||||
import { CustomFields, EndAction, OntimeEvent, SupportedEvent, TimeStrategy, TimerType } from 'ontime-types';
|
||||
import {
|
||||
addToCustomAssignment,
|
||||
calculateDayOffset,
|
||||
handleCustomField,
|
||||
handleLink,
|
||||
hasChanges,
|
||||
isDataStale,
|
||||
} from '../rundownCache.utils.js';
|
||||
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
||||
import { makeOntimeBlock, makeOntimeEvent } from '../__mocks__/rundown.mocks.js';
|
||||
|
||||
describe('handleLink()', () => {
|
||||
it('populates data in object and updates link map', () => {
|
||||
const entries: RundownEntries = {
|
||||
'1': makeOntimeEvent({ id: '1', timeEnd: 100 }),
|
||||
'2': makeOntimeEvent({ id: '2', timeStart: 0, linkStart: '1' }),
|
||||
};
|
||||
|
||||
const mutableEvent = { ...entries[2] } as OntimeEvent;
|
||||
const links = {};
|
||||
|
||||
const result = handleLink(mutableEvent, entries[1] as OntimeEvent, links);
|
||||
expect(result).toBeUndefined();
|
||||
expect(mutableEvent.timeStart).toBe(100);
|
||||
expect(mutableEvent.linkStart).toBe('1');
|
||||
expect(links).toStrictEqual({ '1': '2' });
|
||||
});
|
||||
|
||||
it('removes link if linked event is not found', () => {
|
||||
const entries: RundownEntries = {
|
||||
'1': makeOntimeBlock({ id: '1' }),
|
||||
'2': makeOntimeEvent({ id: '2', timeStart: 0, linkStart: '1' }),
|
||||
};
|
||||
const mutableEvent = { ...entries[2] } as OntimeEvent;
|
||||
const links = {};
|
||||
|
||||
const result = handleLink(mutableEvent, null, links);
|
||||
expect(result).toBeUndefined();
|
||||
expect(mutableEvent.timeStart).toBe(0);
|
||||
expect(mutableEvent.linkStart).toBe('true');
|
||||
expect(links).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
import { makeOntimeEvent } from '../__mocks__/rundown.mocks.js';
|
||||
|
||||
describe('addToCustomAssignment()', () => {
|
||||
it('adds given entry to assignedCustomFields', () => {
|
||||
@@ -77,18 +35,17 @@ describe('handleCustomField()', () => {
|
||||
label: 'sound',
|
||||
},
|
||||
} as CustomFields;
|
||||
const customFieldChangelog = new Map<string, string>();
|
||||
const customFieldChangelog = {};
|
||||
|
||||
// @ts-expect-error -- partial event for testing
|
||||
const event: OntimeEvent = {
|
||||
const event = makeOntimeEvent({
|
||||
type: SupportedEvent.Event,
|
||||
id: '2',
|
||||
timeStart: 0,
|
||||
linkStart: '1',
|
||||
linkStart: true,
|
||||
custom: {
|
||||
lighting: 'on',
|
||||
},
|
||||
};
|
||||
});
|
||||
const assignedCustomFields = {};
|
||||
|
||||
const result = handleCustomField(customFields, customFieldChangelog, event, assignedCustomFields);
|
||||
@@ -113,18 +70,17 @@ describe('handleCustomField()', () => {
|
||||
},
|
||||
} as CustomFields;
|
||||
|
||||
const customFieldChangelog = new Map([['sound', 'video']]);
|
||||
const customFieldChangelog = { sound: 'video' };
|
||||
|
||||
// @ts-expect-error -- partial event for testing
|
||||
const event: OntimeEvent = {
|
||||
const event = makeOntimeEvent({
|
||||
type: SupportedEvent.Event,
|
||||
id: '2',
|
||||
timeStart: 0,
|
||||
linkStart: '1',
|
||||
linkStart: true,
|
||||
custom: {
|
||||
sound: 'on',
|
||||
},
|
||||
};
|
||||
});
|
||||
const assignedCustomFields = {};
|
||||
|
||||
const result = handleCustomField(customFields, customFieldChangelog, event, assignedCustomFields);
|
||||
@@ -149,17 +105,16 @@ describe('handleCustomField()', () => {
|
||||
},
|
||||
} as CustomFields;
|
||||
|
||||
const customFieldChangelog = new Map([['field1', 'newField1']]);
|
||||
const customFieldChangelog = { field1: 'newField1' };
|
||||
|
||||
// @ts-expect-error -- partial event for testing
|
||||
const mutableEvent: OntimeEvent = {
|
||||
const mutableEvent = makeOntimeEvent({
|
||||
type: SupportedEvent.Event,
|
||||
id: 'event1',
|
||||
custom: {
|
||||
field1: 'value1',
|
||||
field2: 'value2',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const assignedCustomFields = {};
|
||||
|
||||
@@ -186,7 +141,7 @@ describe('isDataStale()', () => {
|
||||
{ timeStart: 10 },
|
||||
{ timeEnd: 10 },
|
||||
{ duration: 10 },
|
||||
{ linkStart: '1' },
|
||||
{ linkStart: true },
|
||||
{ timerStrategy: TimeStrategy.LockDuration },
|
||||
];
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ export function apply(delayId: EntryId, rundown: Rundown): Rundown {
|
||||
lastEntry = { ...currentEntry };
|
||||
|
||||
if (shouldUnlink) {
|
||||
currentEntry.linkStart = null;
|
||||
currentEntry.linkStart = false;
|
||||
shouldUnlink = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { CustomFieldLabel, EntryId, MaybeNumber } from 'ontime-types';
|
||||
|
||||
export type RundownMetadata = {
|
||||
totalDelay: number;
|
||||
totalDuration: number;
|
||||
totalDays: number;
|
||||
firstStart: MaybeNumber;
|
||||
lastEnd: MaybeNumber;
|
||||
|
||||
playableEventOrder: EntryId[];
|
||||
timedEventOrder: EntryId[];
|
||||
flatEventOrder: EntryId[];
|
||||
|
||||
/**
|
||||
* Keep track of which custom fields are used.
|
||||
* This will be handy for when we delete custom fields
|
||||
* since we can clear the custom fields from every event where they are used
|
||||
*/
|
||||
assignedCustomFields: Record<CustomFieldLabel, string[]>;
|
||||
};
|
||||
@@ -4,33 +4,24 @@ import {
|
||||
CustomFields,
|
||||
EntryId,
|
||||
isOntimeBlock,
|
||||
isOntimeDelay,
|
||||
isOntimeEvent,
|
||||
isPlayableEvent,
|
||||
MaybeNumber,
|
||||
OntimeBlock,
|
||||
OntimeEvent,
|
||||
OntimeEntry,
|
||||
PlayableEvent,
|
||||
Rundown,
|
||||
RundownEntries,
|
||||
} from 'ontime-types';
|
||||
import {
|
||||
generateId,
|
||||
insertAtIndex,
|
||||
reorderArray,
|
||||
swapEventData,
|
||||
getTimeFromPrevious,
|
||||
isNewLatest,
|
||||
customFieldLabelToKey,
|
||||
} from 'ontime-utils';
|
||||
import { generateId, insertAtIndex, reorderArray, swapEventData, customFieldLabelToKey } from 'ontime-utils';
|
||||
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { createPatch } from '../../utils/parser.js';
|
||||
|
||||
import type { RundownMetadata } from './rundown.types.js';
|
||||
import { apply } from './delayUtils.js';
|
||||
import { calculateDayOffset, handleCustomField, handleLink, hasChanges, isDataStale } from './rundownCacheUtils.js';
|
||||
import { hasChanges, isDataStale, makeRundownMetadata, type ProcessedRundownMetadata } from './rundownCache.utils.js';
|
||||
|
||||
/** We hold the currently selected rundown and its metadata in memory */
|
||||
let currentRundownId: EntryId = '';
|
||||
let currentRundown: Rundown = {
|
||||
id: '',
|
||||
@@ -39,17 +30,26 @@ let currentRundown: Rundown = {
|
||||
entries: {},
|
||||
revision: 0,
|
||||
};
|
||||
let persistedCustomFields: CustomFields = {};
|
||||
let projectCustomFields: CustomFields = {};
|
||||
let rundownMetadata: RundownMetadata = {
|
||||
totalDelay: 0,
|
||||
totalDuration: 0,
|
||||
totalDays: 0,
|
||||
firstStart: null,
|
||||
lastEnd: null,
|
||||
|
||||
playableEventOrder: [],
|
||||
timedEventOrder: [],
|
||||
flatEventOrder: [],
|
||||
|
||||
assignedCustomFields: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the cached rundown without triggering regeneration
|
||||
*/
|
||||
export const getCurrentRundown = (): Rundown => currentRundown;
|
||||
export const getCustomFields = (): CustomFields => persistedCustomFields;
|
||||
|
||||
let playableEventsOrder: EntryId[] = [];
|
||||
let timedEventsOrder: EntryId[] = [];
|
||||
let flatIndexOrder: EntryId[] = [];
|
||||
export const getCustomFields = (): CustomFields => projectCustomFields;
|
||||
|
||||
/**
|
||||
* all mutating functions will set this value if there is a need for re-generation
|
||||
@@ -62,161 +62,111 @@ function setIsStale() {
|
||||
isStale = true;
|
||||
}
|
||||
|
||||
let totalDelay = 0;
|
||||
let totalDuration = 0;
|
||||
let totalDays = 0;
|
||||
let firstStart: MaybeNumber = null;
|
||||
let lastEnd: MaybeNumber = null;
|
||||
|
||||
let links: Record<EntryId, EntryId> = {};
|
||||
|
||||
/**
|
||||
* Object that contains reference of renamed custom fields
|
||||
* Used to rename the custom fields in the events
|
||||
* @private exported only to simplify testing
|
||||
* @example
|
||||
* {
|
||||
* oldLabel: newLabel
|
||||
* lighting: lx
|
||||
* }
|
||||
*/
|
||||
export const customFieldChangelog = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Keep track of which custom fields are used.
|
||||
* This will be handy for when we delete custom fields
|
||||
*/
|
||||
let assignedCustomFields: Record<CustomFieldLabel, EntryId[]> = {};
|
||||
export let customFieldChangelog: Record<string, string> = {};
|
||||
|
||||
/**
|
||||
* Receives a rundown which will be processed and used as the new current rundown
|
||||
*/
|
||||
export async function init(initialRundown: Rundown, customFields: Readonly<CustomFields>) {
|
||||
export async function init(initialRundown: Readonly<Rundown>, customFields: Readonly<CustomFields>) {
|
||||
// TODO: do we need to clone?
|
||||
currentRundown = structuredClone(initialRundown);
|
||||
currentRundownId = initialRundown.id;
|
||||
persistedCustomFields = structuredClone(customFields);
|
||||
projectCustomFields = structuredClone(customFields);
|
||||
generate();
|
||||
|
||||
// TODO: we may not need to persist this data since it should come from the database
|
||||
// update the persisted data
|
||||
await getDataProvider().setRundown(currentRundownId, currentRundown);
|
||||
await getDataProvider().setCustomFields(customFields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility generate cache
|
||||
* @private should not be called outside of `rundownCache.ts`
|
||||
* @private should not be called outside of `rundownCache.ts`, exported for testing
|
||||
*/
|
||||
export function generate(initialRundown: Rundown = currentRundown, customFields: CustomFields = persistedCustomFields) {
|
||||
export function generate(
|
||||
initialRundown: Readonly<Rundown> = currentRundown,
|
||||
customFields: Readonly<CustomFields> = projectCustomFields,
|
||||
): ProcessedRundownMetadata {
|
||||
// The stale state can only be cleared inside generate()
|
||||
function clearIsStale() {
|
||||
isStale = false;
|
||||
}
|
||||
|
||||
// we decided to re-write this dataset for every change
|
||||
// instead of maintaining logic to update it
|
||||
|
||||
assignedCustomFields = {};
|
||||
playableEventsOrder = [];
|
||||
timedEventsOrder = [];
|
||||
flatIndexOrder = [];
|
||||
|
||||
links = {};
|
||||
firstStart = null;
|
||||
lastEnd = null;
|
||||
totalDuration = 0;
|
||||
totalDays = 0;
|
||||
totalDelay = 0;
|
||||
|
||||
// temporary parsed rundown
|
||||
const parsedEntries: RundownEntries = {};
|
||||
const parsedOrder: EntryId[] = [];
|
||||
|
||||
/** A playableEvent from the previous iteration */
|
||||
let previousEntry: PlayableEvent | null = null;
|
||||
/** The playableEvent most forwards in time processed so far */
|
||||
let lastEntry: PlayableEvent | null = null;
|
||||
const { process, getMetadata } = makeRundownMetadata(customFields, customFieldChangelog);
|
||||
|
||||
for (let i = 0; i < initialRundown.order.length; i++) {
|
||||
// we assign a reference to the current entry, this will be mutated in place
|
||||
const currentEntryId = initialRundown.order[i];
|
||||
const currentEntry = initialRundown.entries[currentEntryId];
|
||||
flatIndexOrder.push(currentEntryId);
|
||||
const { processedEntry } = process(currentEntry, null);
|
||||
|
||||
if (isOntimeEvent(currentEntry)) {
|
||||
currentEntry.delay = 0;
|
||||
currentEntry.gap = 0;
|
||||
timedEventsOrder.push(currentEntryId);
|
||||
// if the event is a block, we process the nested entries
|
||||
// the code here is a copy of the processing of top level events
|
||||
if (isOntimeBlock(processedEntry)) {
|
||||
let totalBlockDuration = 0;
|
||||
let blockStartTime = null;
|
||||
let blockEndTime = null;
|
||||
let isFirstLinked = false;
|
||||
|
||||
// 1. handle links - mutates currentEntry and links
|
||||
handleLink(currentEntry, previousEntry, links);
|
||||
// check if the block contains events
|
||||
for (let i = 0; i < processedEntry.events.length; i++) {
|
||||
const nestedEntryId = processedEntry.events[i];
|
||||
const nestedEntry = initialRundown.entries[nestedEntryId];
|
||||
const { processedData: processedNestedData, processedEntry: processedNestedEntry } = process(
|
||||
nestedEntry,
|
||||
processedEntry.id,
|
||||
);
|
||||
|
||||
// 2. handle custom fields - mutates currentEntry
|
||||
handleCustomField(customFields, customFieldChangelog, currentEntry, assignedCustomFields);
|
||||
|
||||
totalDays += calculateDayOffset(currentEntry, lastEntry);
|
||||
currentEntry.dayOffset = totalDays;
|
||||
|
||||
// update rundown metadata, it only concerns playable events
|
||||
if (isPlayableEvent(currentEntry)) {
|
||||
playableEventsOrder.push(currentEntryId);
|
||||
// fist start is always the first event
|
||||
if (firstStart === null) {
|
||||
firstStart = currentEntry.timeStart;
|
||||
// we dont extract metadata of skipped events,
|
||||
// if this is not a playable event there is nothing else to do
|
||||
if (!isOntimeEvent(processedNestedEntry) || !isPlayableEvent(processedNestedEntry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
currentEntry.gap = getTimeFromPrevious(currentEntry, lastEntry);
|
||||
|
||||
if (currentEntry.gap === 0) {
|
||||
// event starts on previous finish, we add its duration
|
||||
totalDuration += currentEntry.duration;
|
||||
} else if (currentEntry.gap > 0) {
|
||||
// event has a gap, we add the gap and the duration
|
||||
totalDuration += currentEntry.gap + currentEntry.duration;
|
||||
} else if (currentEntry.gap < 0) {
|
||||
// there is an overlap, we remove the overlap from the duration
|
||||
// ensuring that the sum is not negative (ie: fully overlapped events)
|
||||
// NOTE: we add the gap since it is a negative number
|
||||
totalDuration += Math.max(currentEntry.duration + currentEntry.gap, 0);
|
||||
// first start is always the first event
|
||||
if (blockStartTime === null) {
|
||||
blockStartTime = processedNestedEntry.timeStart;
|
||||
isFirstLinked = Boolean(processedNestedEntry.linkStart);
|
||||
}
|
||||
|
||||
// remove eventual gaps from the accumulated delay
|
||||
// we only affect positive delays (time forwards)
|
||||
if (totalDelay > 0 && currentEntry.gap > 0) {
|
||||
totalDelay = Math.max(totalDelay - currentEntry.gap, 0);
|
||||
}
|
||||
// current event delay is the current accumulated delay
|
||||
currentEntry.delay = totalDelay;
|
||||
|
||||
previousEntry = currentEntry;
|
||||
// lastEntry is the event with the latest end time
|
||||
if (isNewLatest(currentEntry, lastEntry)) {
|
||||
lastEntry = currentEntry;
|
||||
}
|
||||
blockEndTime = processedNestedData.lastEnd;
|
||||
totalBlockDuration += processedNestedEntry.duration;
|
||||
}
|
||||
} else if (isOntimeDelay(currentEntry)) {
|
||||
// calculate delays
|
||||
// !!! this must happen after handling the links
|
||||
totalDelay += currentEntry.duration;
|
||||
} else if (isOntimeBlock(currentEntry)) {
|
||||
// calculate block - nothing yet
|
||||
} else {
|
||||
// unknown - type skip it
|
||||
// this is needed to get the type guard working when we assign the entry to the rundown
|
||||
continue;
|
||||
}
|
||||
|
||||
// add id to order
|
||||
parsedOrder.push(currentEntry.id);
|
||||
// add entry to rundown
|
||||
parsedEntries[currentEntry.id] = currentEntry;
|
||||
// update block metadata
|
||||
processedEntry.duration = totalBlockDuration;
|
||||
processedEntry.startTime = blockStartTime;
|
||||
processedEntry.endTime = blockEndTime;
|
||||
processedEntry.isFirstLinked = isFirstLinked;
|
||||
processedEntry.numEvents = processedEntry.events.length;
|
||||
}
|
||||
}
|
||||
|
||||
lastEnd = lastEntry?.timeEnd ?? null;
|
||||
const processedData = getMetadata();
|
||||
clearIsStale();
|
||||
customFieldChangelog.clear();
|
||||
customFieldChangelog = {};
|
||||
|
||||
// update the cache values
|
||||
currentRundown.entries = parsedEntries;
|
||||
currentRundown.order = parsedOrder;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
|
||||
const { entries, order, previousEvent, latestEvent, ...metadata } = processedData;
|
||||
currentRundown.entries = entries;
|
||||
currentRundown.order = order;
|
||||
rundownMetadata = metadata;
|
||||
|
||||
// The return value is used for testing
|
||||
return { rundown: parsedEntries, order: parsedOrder, links, totalDelay, totalDuration, assignedCustomFields };
|
||||
return processedData;
|
||||
}
|
||||
|
||||
/** Returns an ID guaranteed to be unique */
|
||||
@@ -271,33 +221,22 @@ export function get(): Readonly<RundownCache> {
|
||||
entries: currentRundown.entries,
|
||||
order: currentRundown.order,
|
||||
revision: currentRundown.revision,
|
||||
totalDelay,
|
||||
totalDuration,
|
||||
totalDelay: rundownMetadata.totalDelay,
|
||||
totalDuration: rundownMetadata.totalDuration,
|
||||
};
|
||||
}
|
||||
|
||||
export type RundownMetadata = {
|
||||
firstStart: MaybeNumber;
|
||||
lastEnd: MaybeNumber;
|
||||
totalDelay: number;
|
||||
totalDuration: number;
|
||||
revision: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns calculated metadata from rundown
|
||||
* Will triggering regeneration if data is stale.
|
||||
*/
|
||||
export function getMetadata(): Readonly<RundownMetadata> {
|
||||
export function getMetadata(): Readonly<RundownMetadata & { revision: number }> {
|
||||
if (isStale) {
|
||||
generate();
|
||||
}
|
||||
|
||||
return {
|
||||
firstStart,
|
||||
lastEnd,
|
||||
totalDelay,
|
||||
totalDuration,
|
||||
...rundownMetadata,
|
||||
revision: currentRundown.revision,
|
||||
};
|
||||
}
|
||||
@@ -317,8 +256,8 @@ export function getEventOrder(): Readonly<RundownOrder> {
|
||||
}
|
||||
return {
|
||||
order: currentRundown.order,
|
||||
timedEventsOrder,
|
||||
playableEventsOrder,
|
||||
timedEventsOrder: rundownMetadata.timedEventOrder,
|
||||
playableEventsOrder: rundownMetadata.playableEventOrder,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -536,11 +475,8 @@ export function swap({ rundown, fromId, toId }: SwapArgs): MutatingReturn {
|
||||
* Utility for invalidating service cache if a custom field is used
|
||||
*/
|
||||
function invalidateIfUsed(label: CustomFieldLabel) {
|
||||
if (label in assignedCustomFields) {
|
||||
setIsStale();
|
||||
}
|
||||
// if the field was in use, we mark the cache as stale
|
||||
if (label in assignedCustomFields) {
|
||||
if (label in rundownMetadata.assignedCustomFields) {
|
||||
setIsStale();
|
||||
}
|
||||
// ... and schedule a cache update
|
||||
@@ -556,7 +492,7 @@ function invalidateIfUsed(label: CustomFieldLabel) {
|
||||
*/
|
||||
function scheduleCustomFieldPersist() {
|
||||
setImmediate(async () => {
|
||||
await getDataProvider().setCustomFields(persistedCustomFields);
|
||||
await getDataProvider().setCustomFields(projectCustomFields);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -572,29 +508,29 @@ export function createCustomField(field: CustomField): CustomFields {
|
||||
}
|
||||
|
||||
// check if label already exists
|
||||
const alreadyExists = Object.hasOwn(persistedCustomFields, key);
|
||||
const alreadyExists = Object.hasOwn(projectCustomFields, key);
|
||||
|
||||
if (alreadyExists) {
|
||||
throw new Error('Label already exists');
|
||||
}
|
||||
|
||||
// update object and persist
|
||||
persistedCustomFields[key] = { label, type, colour };
|
||||
projectCustomFields[key] = { label, type, colour };
|
||||
|
||||
scheduleCustomFieldPersist();
|
||||
|
||||
return persistedCustomFields;
|
||||
return projectCustomFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits an existing custom field in the database
|
||||
*/
|
||||
export function editCustomField(key: string, newField: Partial<CustomField>): CustomFields {
|
||||
if (!(key in persistedCustomFields)) {
|
||||
if (!(key in projectCustomFields)) {
|
||||
throw new Error('Could not find label');
|
||||
}
|
||||
|
||||
const existingField = persistedCustomFields[key];
|
||||
const existingField = projectCustomFields[key];
|
||||
if (newField.type !== undefined && existingField.type !== newField.type) {
|
||||
throw new Error('Change of field type is not allowed');
|
||||
}
|
||||
@@ -607,29 +543,29 @@ export function editCustomField(key: string, newField: Partial<CustomField>): Cu
|
||||
if (newKey === null) {
|
||||
throw new Error('Unable to convert label to a valid key');
|
||||
}
|
||||
persistedCustomFields[newKey] = { ...existingField, ...newField };
|
||||
projectCustomFields[newKey] = { ...existingField, ...newField };
|
||||
|
||||
if (key !== newKey) {
|
||||
delete persistedCustomFields[key];
|
||||
customFieldChangelog.set(key, newKey);
|
||||
delete projectCustomFields[key];
|
||||
customFieldChangelog[key] = newKey;
|
||||
}
|
||||
|
||||
scheduleCustomFieldPersist();
|
||||
invalidateIfUsed(key);
|
||||
|
||||
return persistedCustomFields;
|
||||
return projectCustomFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a custom field from the database
|
||||
*/
|
||||
export function removeCustomField(label: string): CustomFields {
|
||||
if (label in persistedCustomFields) {
|
||||
delete persistedCustomFields[label];
|
||||
if (label in projectCustomFields) {
|
||||
delete projectCustomFields[label];
|
||||
}
|
||||
|
||||
scheduleCustomFieldPersist();
|
||||
invalidateIfUsed(label);
|
||||
|
||||
return persistedCustomFields;
|
||||
return projectCustomFields;
|
||||
}
|
||||
|
||||
@@ -1,38 +1,19 @@
|
||||
import { OntimeEvent, CustomFieldLabel, CustomFields, OntimeEntry, OntimeBaseEvent } from 'ontime-types';
|
||||
import { dayInMs, getLinkedTimes } from 'ontime-utils';
|
||||
import {
|
||||
OntimeEvent,
|
||||
CustomFieldLabel,
|
||||
CustomFields,
|
||||
OntimeEntry,
|
||||
OntimeBaseEvent,
|
||||
EntryId,
|
||||
isOntimeEvent,
|
||||
isPlayableEvent,
|
||||
isOntimeDelay,
|
||||
PlayableEvent,
|
||||
RundownEntries,
|
||||
} from 'ontime-types';
|
||||
import { dayInMs, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils';
|
||||
|
||||
/**
|
||||
* Checks that link can be established (ie, events exist and are valid)
|
||||
* and populates the time data from link
|
||||
* With the current implementation, the links is always the previous playable event
|
||||
* Mutates mutableEvent in place
|
||||
* Mutates links in place
|
||||
*/
|
||||
export function handleLink(
|
||||
mutableEvent: OntimeEvent,
|
||||
previousEvent: OntimeEvent | null,
|
||||
links: Record<string, string>,
|
||||
): void {
|
||||
if (!mutableEvent.linkStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* If no previous event exist, we dont remove the link
|
||||
* this means that the event will keep the behaviour in case a new event is added before
|
||||
* However, we do add its ID to the links and prevent out-of-sync data
|
||||
*/
|
||||
if (!previousEvent) {
|
||||
mutableEvent.linkStart = 'true';
|
||||
return;
|
||||
}
|
||||
|
||||
const timePatch = getLinkedTimes(mutableEvent, previousEvent);
|
||||
mutableEvent.linkStart = previousEvent.id;
|
||||
links[previousEvent.id] = mutableEvent.id;
|
||||
// use object.assign to force mutation
|
||||
Object.assign(mutableEvent, timePatch);
|
||||
}
|
||||
import type { RundownMetadata } from './rundown.types.js';
|
||||
|
||||
/**
|
||||
* Utility function to add an entry, mutates given assignedCustomFields in place
|
||||
@@ -52,19 +33,19 @@ export function addToCustomAssignment(
|
||||
|
||||
/**
|
||||
* Sanitises custom fields and updates values if necessary
|
||||
* Mudates in place mutableEvent and assignedCustomFields
|
||||
* Mutates in place mutableEvent and assignedCustomFields
|
||||
*/
|
||||
export function handleCustomField(
|
||||
customFields: CustomFields,
|
||||
customFieldChangelog: Map<string, string>,
|
||||
customFieldChangelog: Record<string, string>,
|
||||
mutableEvent: OntimeEvent,
|
||||
assignedCustomFields: Record<string, string[]>,
|
||||
) {
|
||||
for (const field in mutableEvent.custom) {
|
||||
// rename the property if it is in the changelog
|
||||
if (customFieldChangelog.has(field)) {
|
||||
if (field in customFieldChangelog) {
|
||||
const oldData = mutableEvent.custom[field];
|
||||
const newLabel = customFieldChangelog.get(field) as string; // it os OK to cast to string here since we already checked that it existed
|
||||
const newLabel = customFieldChangelog[field];
|
||||
|
||||
mutableEvent.custom[newLabel] = oldData;
|
||||
delete mutableEvent.custom[field];
|
||||
@@ -101,17 +82,15 @@ enum RegenerateWhitelist {
|
||||
|
||||
/**
|
||||
* given a patch, returns whether all keys are whitelisted
|
||||
* @param path
|
||||
*/
|
||||
export function isDataStale(patch: Partial<OntimeEntry>): boolean {
|
||||
return Object.keys(patch).some((key) => !(key in RegenerateWhitelist));
|
||||
return Object.keys(patch).some(willCauseRegeneration);
|
||||
}
|
||||
|
||||
/**
|
||||
* given a key, returns whether it is whitelisted
|
||||
* @param path
|
||||
*/
|
||||
export function willCauseRegeneration(key: keyof OntimeEvent): boolean {
|
||||
export function willCauseRegeneration(key: string): boolean {
|
||||
return !(key in RegenerateWhitelist);
|
||||
}
|
||||
|
||||
@@ -159,3 +138,143 @@ export function calculateDayOffset(
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export type ProcessedRundownMetadata = RundownMetadata & {
|
||||
entries: RundownEntries;
|
||||
order: EntryId[];
|
||||
previousEvent: PlayableEvent | null; // The playableEvent from the previous iteration
|
||||
latestEvent: PlayableEvent | null; // The playableEvent most forwards in time processed so far
|
||||
};
|
||||
|
||||
export function makeRundownMetadata(customFields: CustomFields, customFieldChangelog: Record<string, string>) {
|
||||
let rundownMeta: ProcessedRundownMetadata = {
|
||||
totalDelay: 0,
|
||||
totalDuration: 0,
|
||||
totalDays: 0,
|
||||
firstStart: null,
|
||||
lastEnd: null,
|
||||
|
||||
assignedCustomFields: {},
|
||||
playableEventOrder: [],
|
||||
timedEventOrder: [],
|
||||
flatEventOrder: [],
|
||||
|
||||
entries: {},
|
||||
order: [],
|
||||
previousEvent: null,
|
||||
latestEvent: null,
|
||||
};
|
||||
|
||||
function process<T extends OntimeEntry>(
|
||||
entry: T,
|
||||
childOfBlock: EntryId | null,
|
||||
): { processedData: ProcessedRundownMetadata; processedEntry: T } {
|
||||
const data = processEntry(rundownMeta, customFields, customFieldChangelog, entry, childOfBlock);
|
||||
rundownMeta = data.processedData;
|
||||
return data;
|
||||
}
|
||||
|
||||
function getMetadata(): ProcessedRundownMetadata {
|
||||
return rundownMeta;
|
||||
}
|
||||
|
||||
return { process, getMetadata };
|
||||
}
|
||||
|
||||
function processEntry<T extends OntimeEntry>(
|
||||
rundownMetadata: ProcessedRundownMetadata,
|
||||
customFields: CustomFields,
|
||||
customFieldChangelog: Record<string, string>,
|
||||
entry: T,
|
||||
childOfBlock: EntryId | null,
|
||||
): { processedData: ProcessedRundownMetadata; processedEntry: T } {
|
||||
const processedData = { ...rundownMetadata };
|
||||
const currentEntry = structuredClone(entry);
|
||||
processedData.flatEventOrder.push(currentEntry.id);
|
||||
|
||||
if (isOntimeEvent(currentEntry)) {
|
||||
if (!childOfBlock) {
|
||||
processedData.timedEventOrder.push(currentEntry.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 1.Checks that link can be established (ie, events exist and are valid)
|
||||
* and populates the time data from link
|
||||
* The linked event is always the previous playable event
|
||||
* If no previous event exists, the link is removed
|
||||
*/
|
||||
if (currentEntry.linkStart) {
|
||||
if (processedData.previousEvent) {
|
||||
const timePatch = getLinkedTimes(currentEntry, processedData.previousEvent);
|
||||
currentEntry.timeStart = timePatch.timeStart;
|
||||
currentEntry.timeEnd = timePatch.timeEnd;
|
||||
currentEntry.duration = timePatch.duration;
|
||||
} else {
|
||||
currentEntry.linkStart = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. handle custom fields - mutates currentEntry
|
||||
handleCustomField(customFields, customFieldChangelog, currentEntry, processedData.assignedCustomFields);
|
||||
|
||||
processedData.totalDays += calculateDayOffset(currentEntry, processedData.previousEvent);
|
||||
currentEntry.dayOffset = processedData.totalDays;
|
||||
currentEntry.delay = 0; // this means we dont calculate delays or gaps for skipped events
|
||||
currentEntry.gap = 0; // this means we dont calculate delays or gaps for skipped events
|
||||
currentEntry.currentBlock = childOfBlock;
|
||||
|
||||
// update rundown metadata, it only concerns playable events
|
||||
if (isPlayableEvent(currentEntry)) {
|
||||
if (!childOfBlock) {
|
||||
processedData.playableEventOrder.push(currentEntry.id);
|
||||
}
|
||||
|
||||
// first start is always the first event
|
||||
if (processedData.firstStart === null) {
|
||||
processedData.firstStart = currentEntry.timeStart;
|
||||
}
|
||||
|
||||
currentEntry.gap = getTimeFrom(currentEntry, processedData.latestEvent);
|
||||
|
||||
if (currentEntry.gap === 0) {
|
||||
// event starts on previous finish, we add its duration
|
||||
processedData.totalDuration += currentEntry.duration;
|
||||
} else if (currentEntry.gap > 0) {
|
||||
// event has a gap, we add the gap and the duration
|
||||
processedData.totalDuration += currentEntry.gap + currentEntry.duration;
|
||||
} else if (currentEntry.gap < 0) {
|
||||
// there is an overlap, we remove the overlap from the duration
|
||||
// ensuring that the sum is not negative (ie: fully overlapped events)
|
||||
// NOTE: we add the gap since it is a negative number
|
||||
processedData.totalDuration += Math.max(currentEntry.duration + currentEntry.gap, 0);
|
||||
}
|
||||
|
||||
// remove eventual gaps from the accumulated delay
|
||||
// we only affect positive delays (time forwards)
|
||||
if (processedData.totalDelay > 0 && currentEntry.gap > 0) {
|
||||
processedData.totalDelay = Math.max(processedData.totalDelay - currentEntry.gap, 0);
|
||||
}
|
||||
// current event delay is the current accumulated delay
|
||||
currentEntry.delay = processedData.totalDelay;
|
||||
|
||||
// assign data for next iteration
|
||||
processedData.previousEvent = currentEntry;
|
||||
|
||||
// lastEntry is the event with the latest end time
|
||||
if (isNewLatest(currentEntry, processedData.latestEvent)) {
|
||||
processedData.latestEvent = currentEntry;
|
||||
processedData.lastEnd = currentEntry.timeEnd;
|
||||
}
|
||||
}
|
||||
} else if (isOntimeDelay(currentEntry)) {
|
||||
// !!! this must happen after handling the links
|
||||
processedData.totalDelay += currentEntry.duration;
|
||||
}
|
||||
|
||||
if (!childOfBlock) {
|
||||
processedData.order.push(currentEntry.id);
|
||||
}
|
||||
processedData.entries[currentEntry.id] = currentEntry;
|
||||
|
||||
return { processedData, processedEntry: currentEntry };
|
||||
}
|
||||
|
||||
@@ -9,13 +9,15 @@ import { ImportMap, getErrorMessage } from 'ontime-utils';
|
||||
|
||||
import { sheets, type sheets_v4 } from '@googleapis/sheets';
|
||||
import { Credentials, OAuth2Client } from 'google-auth-library';
|
||||
// TODO: rewrite logic to use fetch and remove dependency
|
||||
import got from 'got';
|
||||
|
||||
import { parseExcel } from '../../utils/parser.js';
|
||||
import { logger } from '../../classes/Logger.js';
|
||||
import { parseRundown } from '../../utils/parserFunctions.js';
|
||||
import { getRundown } from '../rundown-service/rundownUtils.js';
|
||||
import { getCustomFields } from '../rundown-service/rundownCache.js';
|
||||
import { parseRundowns } from '../../utils/parserFunctions.js';
|
||||
|
||||
import { getCurrentRundown, getCustomFields } from '../rundown-service/rundownCache.js';
|
||||
import { getRundownOrThrow } from '../rundown-service/rundownUtils.js';
|
||||
|
||||
import { cellRequestFromEvent, type ClientSecret, getA1Notation, isClientSecret } from './sheetUtils.js';
|
||||
import { catchCommonImportXlsxError } from './googleApi.utils.js';
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('cellRequestFromEvent()', () => {
|
||||
timeStart: 46800000,
|
||||
timeEnd: 57600000,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
@@ -81,7 +81,7 @@ describe('cellRequestFromEvent()', () => {
|
||||
countToEnd: false,
|
||||
duration: 10800000,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
isPublic: false,
|
||||
skip: false,
|
||||
colour: 'red',
|
||||
@@ -132,7 +132,7 @@ describe('cellRequestFromEvent()', () => {
|
||||
countToEnd: false,
|
||||
duration: 10800000,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: 'red',
|
||||
@@ -181,7 +181,7 @@ describe('cellRequestFromEvent()', () => {
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
duration: 10800000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
@@ -218,7 +218,7 @@ describe('cellRequestFromEvent()', () => {
|
||||
countToEnd: false,
|
||||
duration: 10800000,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: 'red',
|
||||
@@ -255,7 +255,7 @@ describe('cellRequestFromEvent()', () => {
|
||||
countToEnd: false,
|
||||
duration: 10800000,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: 'red',
|
||||
|
||||
@@ -31,9 +31,9 @@ describe('test parseDatabaseModel() with demo project (valid)', () => {
|
||||
const filteredDemoProject = structuredClone(demoDb);
|
||||
const { data } = parseDatabaseModel(filteredDemoProject);
|
||||
|
||||
it('has 16 events', () => {
|
||||
expect(data.rundowns.demo.order.length).toBe(16);
|
||||
expect(Object.keys(data.rundowns.demo.entries).length).toBe(16);
|
||||
it('has 17 events with 12 top level events', () => {
|
||||
expect(data.rundowns.default.order.length).toBe(12);
|
||||
expect(Object.keys(data.rundowns.default.entries).length).toBe(17);
|
||||
});
|
||||
|
||||
it('is the same as the demo project since all data is valid', () => {
|
||||
@@ -663,7 +663,7 @@ describe('parseExcel()', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('parses link start and checks that is applicable', () => {
|
||||
it('parses link start', () => {
|
||||
const testData = [
|
||||
['Time Start', 'Time End', 'ID', 'Link Start', 'Timer type'],
|
||||
['4:30:00', '9:45:00', 'A', '', 'count-down'],
|
||||
@@ -688,22 +688,22 @@ describe('parseExcel()', () => {
|
||||
|
||||
expect(result.rundown.entries).toMatchObject({
|
||||
A: {
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
},
|
||||
B: {
|
||||
linkStart: 'true', // <--- this will be populated by the cache generation
|
||||
linkStart: true,
|
||||
},
|
||||
C: {
|
||||
linkStart: 'true', // <--- this will be populated by the cache generation
|
||||
linkStart: true,
|
||||
},
|
||||
D: {
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
},
|
||||
BLOCK: {
|
||||
type: SupportedEvent.Block,
|
||||
},
|
||||
E: {
|
||||
linkStart: 'true', // <--- this will be populated by the cache generation
|
||||
linkStart: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -775,7 +775,7 @@ describe('parseExcel()', () => {
|
||||
|
||||
expect(parsedData.rundown.entries['MEET3']).toMatchObject({
|
||||
duration: 90 * MILLIS_PER_MINUTE,
|
||||
linkStart: null,
|
||||
linkStart: false,
|
||||
timeWarning: 11 * MILLIS_PER_MINUTE,
|
||||
timeDanger: 5 * MILLIS_PER_MINUTE,
|
||||
});
|
||||
@@ -783,7 +783,7 @@ describe('parseExcel()', () => {
|
||||
expect(parsedData.rundown.entries['MEET4']).toMatchObject({
|
||||
duration: 30 * MILLIS_PER_MINUTE,
|
||||
timeWarning: 11 * MILLIS_PER_MINUTE,
|
||||
linkStart: 'true', // if we get a boolean, we should just use that
|
||||
linkStart: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
parseViewSettings,
|
||||
sanitiseCustomFields,
|
||||
} from '../parserFunctions.js';
|
||||
import { makeOntimeBlock, makeOntimeEvent } from '../../services/rundown-service/__mocks__/rundown.mocks.js';
|
||||
|
||||
describe('parseRundowns()', () => {
|
||||
it('returns a default project rundown if nothing is given', () => {
|
||||
@@ -166,6 +167,43 @@ describe('parseRundown()', () => {
|
||||
expect(parsedRundown.order.length).toEqual(2);
|
||||
expect(Object.keys(parsedRundown.entries).length).toEqual(2);
|
||||
});
|
||||
|
||||
it('handles empty events', () => {
|
||||
const rundown = {
|
||||
id: 'test',
|
||||
title: '',
|
||||
order: ['1', '2', '3', '4'],
|
||||
entries: {
|
||||
'1': { id: '1', type: SupportedEvent.Event } as OntimeEvent,
|
||||
'2': { id: '2', type: SupportedEvent.Event } as OntimeEvent,
|
||||
'not-mentioned': {} as OntimeEvent,
|
||||
},
|
||||
revision: 1,
|
||||
} as Rundown;
|
||||
|
||||
const parsedRundown = parseRundown(rundown, {});
|
||||
expect(parsedRundown.order.length).toEqual(2);
|
||||
expect(Object.keys(parsedRundown.entries).length).toEqual(2);
|
||||
});
|
||||
|
||||
it('parses events nested in blocks', () => {
|
||||
const rundown = {
|
||||
id: 'test',
|
||||
title: '',
|
||||
order: ['block'],
|
||||
entries: {
|
||||
block: makeOntimeBlock({ id: 'block', events: ['1', '2'] }),
|
||||
'1': makeOntimeEvent({ id: '1' }),
|
||||
'2': makeOntimeEvent({ id: '2' }),
|
||||
},
|
||||
revision: 1,
|
||||
} as Rundown;
|
||||
|
||||
const parsedRundown = parseRundown(rundown, {});
|
||||
expect(parsedRundown.order.length).toEqual(1);
|
||||
expect(parsedRundown.entries.block).toMatchObject({ events: ['1', '2'] });
|
||||
expect(Object.keys(parsedRundown.entries).length).toEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseProject()', () => {
|
||||
@@ -361,123 +399,3 @@ describe('sanitiseCustomFields()', () => {
|
||||
expect(sanitationResult).toStrictEqual(expectedCustomFields);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRundown() linking', () => {
|
||||
it('returns linked events', () => {
|
||||
const rundown: Rundown = {
|
||||
id: '',
|
||||
title: '',
|
||||
revision: 1,
|
||||
order: ['1', '2'],
|
||||
entries: {
|
||||
'1': {
|
||||
id: '1',
|
||||
type: SupportedEvent.Event,
|
||||
skip: false,
|
||||
} as OntimeEvent,
|
||||
'2': {
|
||||
id: '2',
|
||||
type: SupportedEvent.Event,
|
||||
linkStart: 'true',
|
||||
skip: false,
|
||||
} as OntimeEvent,
|
||||
},
|
||||
};
|
||||
|
||||
const result = parseRundown(rundown, {});
|
||||
expect(result).toMatchObject({
|
||||
order: ['1', '2'],
|
||||
entries: {
|
||||
'2': {
|
||||
linkStart: '1',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns unlinked if no previous', () => {
|
||||
const rundown: Rundown = {
|
||||
id: '',
|
||||
title: '',
|
||||
revision: 1,
|
||||
order: ['1', '2'],
|
||||
entries: {
|
||||
'2': {
|
||||
id: '2',
|
||||
type: SupportedEvent.Event,
|
||||
linkStart: 'true',
|
||||
skip: false,
|
||||
} as OntimeEvent,
|
||||
},
|
||||
};
|
||||
|
||||
const result = parseRundown(rundown, {});
|
||||
expect(result).toMatchObject({
|
||||
order: ['2'],
|
||||
entries: {
|
||||
'2': {
|
||||
linkStart: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns linked events past blocks and delays', () => {
|
||||
const rundown: Rundown = {
|
||||
id: '',
|
||||
title: '',
|
||||
revision: 1,
|
||||
order: ['1', 'delay1', '2', 'block1', '3'],
|
||||
entries: {
|
||||
'1': {
|
||||
id: '1',
|
||||
type: SupportedEvent.Event,
|
||||
skip: false,
|
||||
} as OntimeEvent,
|
||||
delay1: {
|
||||
id: 'delay1',
|
||||
type: SupportedEvent.Delay,
|
||||
duration: 0,
|
||||
},
|
||||
'2': {
|
||||
id: '2',
|
||||
type: SupportedEvent.Event,
|
||||
linkStart: 'true',
|
||||
skip: false,
|
||||
} as OntimeEvent,
|
||||
block1: {
|
||||
id: 'block1',
|
||||
type: SupportedEvent.Block,
|
||||
title: '',
|
||||
} as OntimeBlock,
|
||||
'3': {
|
||||
id: '3',
|
||||
type: SupportedEvent.Event,
|
||||
linkStart: 'true',
|
||||
skip: false,
|
||||
} as OntimeEvent,
|
||||
},
|
||||
};
|
||||
|
||||
const result = parseRundown(rundown, {});
|
||||
expect(result).toMatchObject({
|
||||
order: rundown.order,
|
||||
entries: {
|
||||
'1': {
|
||||
id: '1',
|
||||
cue: '1',
|
||||
},
|
||||
'2': {
|
||||
id: '2',
|
||||
cue: '2',
|
||||
linkStart: '1',
|
||||
},
|
||||
'3': {
|
||||
id: '3',
|
||||
cue: '3',
|
||||
linkStart: '2',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
type ImportMap,
|
||||
isKnownTimerType,
|
||||
validateEndAction,
|
||||
validateLinkStart,
|
||||
validateTimerType,
|
||||
validateTimes,
|
||||
} from 'ontime-utils';
|
||||
@@ -246,7 +245,7 @@ export const parseExcel = (
|
||||
} else if (j === timeStartIndex) {
|
||||
entry.timeStart = parseExcelDate(column);
|
||||
} else if (j === linkStartIndex) {
|
||||
entry.linkStart = parseBooleanString(column) ? 'true' : null;
|
||||
entry.linkStart = parseBooleanString(column);
|
||||
} else if (j === timeEndIndex) {
|
||||
entry.timeEnd = parseExcelDate(column);
|
||||
} else if (j === durationIndex) {
|
||||
@@ -412,7 +411,7 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<Onti
|
||||
timeEnd,
|
||||
duration,
|
||||
timeStrategy,
|
||||
linkStart: validateLinkStart(patchEvent.linkStart, originalEvent.linkStart),
|
||||
linkStart: typeof patchEvent.linkStart === 'boolean' ? patchEvent.linkStart : originalEvent.linkStart,
|
||||
endAction: validateEndAction(patchEvent.endAction, originalEvent.endAction),
|
||||
timerType: validateTimerType(patchEvent.timerType, originalEvent.timerType),
|
||||
countToEnd: typeof patchEvent.countToEnd === 'boolean' ? patchEvent.countToEnd : originalEvent.countToEnd,
|
||||
|
||||
@@ -75,11 +75,11 @@ export function parseRundown(
|
||||
};
|
||||
|
||||
let eventIndex = 0;
|
||||
let previousId: string | null = null;
|
||||
|
||||
for (let i = 0; i < rundown.order.length; i++) {
|
||||
const entryId = rundown.order[i];
|
||||
const event = rundown.entries[entryId];
|
||||
|
||||
if (event === undefined) {
|
||||
emitError?.('Could not find referenced event, skipping');
|
||||
continue;
|
||||
@@ -94,13 +94,7 @@ export function parseRundown(
|
||||
let newEvent: OntimeEvent | OntimeDelay | OntimeBlock | null;
|
||||
|
||||
if (isOntimeEvent(event)) {
|
||||
const maybeEvent = { ...event };
|
||||
|
||||
if (event.linkStart) {
|
||||
maybeEvent.linkStart = previousId;
|
||||
}
|
||||
|
||||
newEvent = createEvent(maybeEvent, eventIndex);
|
||||
newEvent = createEvent(event, eventIndex);
|
||||
// skip if event is invalid
|
||||
if (newEvent == null) {
|
||||
emitError?.('Skipping event without payload');
|
||||
@@ -115,11 +109,38 @@ export function parseRundown(
|
||||
}
|
||||
}
|
||||
|
||||
previousId = id;
|
||||
eventIndex += 1;
|
||||
} else if (isOntimeDelay(event)) {
|
||||
newEvent = { ...delayDef, duration: event.duration, id };
|
||||
} else if (isOntimeBlock(event)) {
|
||||
for (let i = 0; i < event.events.length; i++) {
|
||||
const nestedEventId = event.events[i];
|
||||
const nestedEvent = rundown.entries[nestedEventId];
|
||||
|
||||
if (isOntimeEvent(nestedEvent)) {
|
||||
const newNestedEvent = createEvent(nestedEvent, eventIndex);
|
||||
// skip if event is invalid
|
||||
if (newNestedEvent == null) {
|
||||
emitError?.('Skipping event without payload');
|
||||
continue;
|
||||
}
|
||||
|
||||
// for every field in custom, check that a key exists in customfields
|
||||
for (const field in newNestedEvent.custom) {
|
||||
if (!Object.hasOwn(parsedCustomFields, field)) {
|
||||
emitError?.(`Custom field ${field} not found`);
|
||||
delete newNestedEvent.custom[field];
|
||||
}
|
||||
}
|
||||
|
||||
eventIndex += 1;
|
||||
|
||||
if (newNestedEvent) {
|
||||
parsedRundown.entries[nestedEventId] = newNestedEvent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newEvent = {
|
||||
...blockDef,
|
||||
title: event.title,
|
||||
|
||||
+14
-14
@@ -31,7 +31,7 @@
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"countToEnd": false,
|
||||
"linkStart": null,
|
||||
"linkStart": false,
|
||||
"timeStrategy": "lock-end",
|
||||
"timeStart": 36000000,
|
||||
"timeEnd": 37200000,
|
||||
@@ -60,7 +60,7 @@
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"countToEnd": false,
|
||||
"linkStart": null,
|
||||
"linkStart": false,
|
||||
"timeStrategy": "lock-end",
|
||||
"timeStart": 37500000,
|
||||
"timeEnd": 38700000,
|
||||
@@ -89,7 +89,7 @@
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"countToEnd": false,
|
||||
"linkStart": null,
|
||||
"linkStart": false,
|
||||
"timeStrategy": "lock-end",
|
||||
"timeStart": 39000000,
|
||||
"timeEnd": 40200000,
|
||||
@@ -118,7 +118,7 @@
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"countToEnd": false,
|
||||
"linkStart": null,
|
||||
"linkStart": false,
|
||||
"timeStrategy": "lock-end",
|
||||
"timeStart": 40500000,
|
||||
"timeEnd": 41700000,
|
||||
@@ -147,7 +147,7 @@
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"countToEnd": false,
|
||||
"linkStart": null,
|
||||
"linkStart": false,
|
||||
"timeStrategy": "lock-end",
|
||||
"timeStart": 42000000,
|
||||
"timeEnd": 43200000,
|
||||
@@ -192,7 +192,7 @@
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"countToEnd": false,
|
||||
"linkStart": null,
|
||||
"linkStart": false,
|
||||
"timeStrategy": "lock-end",
|
||||
"timeStart": 47100000,
|
||||
"timeEnd": 48300000,
|
||||
@@ -221,7 +221,7 @@
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"countToEnd": false,
|
||||
"linkStart": null,
|
||||
"linkStart": false,
|
||||
"timeStrategy": "lock-end",
|
||||
"timeStart": 48600000,
|
||||
"timeEnd": 49800000,
|
||||
@@ -250,7 +250,7 @@
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"countToEnd": false,
|
||||
"linkStart": null,
|
||||
"linkStart": false,
|
||||
"timeStrategy": "lock-end",
|
||||
"timeStart": 50100000,
|
||||
"timeEnd": 51300000,
|
||||
@@ -279,7 +279,7 @@
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"countToEnd": false,
|
||||
"linkStart": null,
|
||||
"linkStart": false,
|
||||
"timeStrategy": "lock-end",
|
||||
"timeStart": 51600000,
|
||||
"timeEnd": 52800000,
|
||||
@@ -308,7 +308,7 @@
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"countToEnd": false,
|
||||
"linkStart": null,
|
||||
"linkStart": false,
|
||||
"timeStrategy": "lock-end",
|
||||
"timeStart": 53100000,
|
||||
"timeEnd": 54300000,
|
||||
@@ -353,7 +353,7 @@
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"countToEnd": false,
|
||||
"linkStart": null,
|
||||
"linkStart": false,
|
||||
"timeStrategy": "lock-end",
|
||||
"timeStart": 56100000,
|
||||
"timeEnd": 57300000,
|
||||
@@ -382,7 +382,7 @@
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"countToEnd": false,
|
||||
"linkStart": null,
|
||||
"linkStart": false,
|
||||
"timeStrategy": "lock-end",
|
||||
"timeStart": 57600000,
|
||||
"timeEnd": 58800000,
|
||||
@@ -411,7 +411,7 @@
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"countToEnd": false,
|
||||
"linkStart": null,
|
||||
"linkStart": false,
|
||||
"timeStrategy": "lock-end",
|
||||
"timeStart": 59100000,
|
||||
"timeEnd": 60300000,
|
||||
@@ -440,7 +440,7 @@
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"countToEnd": false,
|
||||
"linkStart": null,
|
||||
"linkStart": false,
|
||||
"timeStrategy": "lock-end",
|
||||
"timeStart": 60600000,
|
||||
"timeEnd": 61800000,
|
||||
|
||||
@@ -13,7 +13,7 @@ test('project file upload', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Delete all' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'toggle settings' }).click();
|
||||
await page.getByRole('button', { name: 'Project', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Manage projects' }).click();
|
||||
|
||||
// workaround to upload file on hidden input
|
||||
// https://playwright.dev/docs/api/class-filechooser
|
||||
@@ -39,14 +39,14 @@ test('project file download', async ({ page }) => {
|
||||
await page.goto('http://localhost:4001/editor');
|
||||
|
||||
await page.getByRole('button', { name: 'toggle settings' }).click();
|
||||
await page.getByRole('button', { name: 'Project', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Manage projects' }).click();
|
||||
|
||||
// workaround to download
|
||||
// https://playwright.dev/docs/api/class-download
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
|
||||
await page
|
||||
.getByRole('row', { name: /^e2e-test-db/ })
|
||||
.getByRole('row', { name: /.*currently loaded/i })
|
||||
.getByLabel('Options')
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: 'Download' }).click();
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Copy Past', async ({ page }) => {
|
||||
test('Copy-paste', async ({ page }) => {
|
||||
await page.goto('http://localhost:4001/rundown');
|
||||
|
||||
// clear rundown
|
||||
await page.getByRole('button', { name: 'Clear rundown' }).click();
|
||||
await page.getByRole('button', { name: 'Delete all' }).click();
|
||||
|
||||
//create event
|
||||
// create event
|
||||
await page.getByRole('button', { name: 'Create Event' }).click();
|
||||
await page.getByTestId('entry-1').click();
|
||||
await page.getByLabel('Cue', { exact: true }).click();
|
||||
@@ -18,22 +18,22 @@ test('Copy Past', async ({ page }) => {
|
||||
await page.getByTestId('block__title').fill('test');
|
||||
await page.getByTestId('block__title').press('Enter');
|
||||
|
||||
//copy past below
|
||||
// copy paste below
|
||||
await page.locator('div').filter({ hasText: /^4$/ }).click();
|
||||
await page.locator('div').filter({ hasText: /^4$/ }).press('Control+c');
|
||||
await page.locator('div').filter({ hasText: /^4$/ }).press('Control+v');
|
||||
|
||||
//assert
|
||||
// assert
|
||||
await expect(page.getByTestId('entry-2')).toBeVisible();
|
||||
await expect(page.getByTestId('entry-2').getByTestId('block__title')).toHaveValue('test');
|
||||
await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('5');
|
||||
|
||||
//copy past above
|
||||
// copy paste above
|
||||
await page.locator('div').filter({ hasText: /^5$/ }).click();
|
||||
await page.locator('div').filter({ hasText: /^5$/ }).press('Control+c');
|
||||
await page.locator('div').filter({ hasText: /^5$/ }).press('Control+Shift+v');
|
||||
|
||||
//assert
|
||||
// assert
|
||||
await expect(page.getByTestId('entry-2')).toBeVisible();
|
||||
await expect(page.getByTestId('entry-2').getByTestId('block__title')).toHaveValue('test');
|
||||
await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('4.1');
|
||||
@@ -46,17 +46,17 @@ test('Move', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Clear rundown' }).click();
|
||||
await page.getByRole('button', { name: 'Delete all' }).click();
|
||||
|
||||
//create events
|
||||
// create events
|
||||
await page.getByRole('button', { name: 'Create Event' }).click();
|
||||
await page.getByRole('button', { name: 'Event' }).nth(4).click();
|
||||
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||
|
||||
//copy move down
|
||||
// copy move down
|
||||
await page.getByTestId('entry-1').locator('#event-block').getByText('1').click();
|
||||
await page.getByTestId('entry-1').locator('#event-block div').filter({ hasText: '1' }).press('Alt+Control+ArrowDown');
|
||||
await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('1');
|
||||
|
||||
//copy move up
|
||||
// copy move up
|
||||
await page.getByTestId('entry-3').locator('#event-block').getByText('3').click();
|
||||
await page.getByTestId('entry-3').locator('#event-block div').filter({ hasText: '3' }).press('Alt+Control+ArrowUp');
|
||||
await page.getByTestId('entry-2').locator('div').filter({ hasText: /^3$/ }).press('Alt+Control+ArrowUp');
|
||||
@@ -70,18 +70,25 @@ test('Add block', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Clear rundown' }).click();
|
||||
await page.getByRole('button', { name: 'Delete all' }).click();
|
||||
|
||||
//create events
|
||||
// create events
|
||||
await page.getByRole('button', { name: 'Create Event' }).click();
|
||||
await page.getByPlaceholder(/event title/i).fill('test');
|
||||
await page.getByTestId('entry-1').click();
|
||||
await page.getByTestId('block__title').press('Escape');
|
||||
|
||||
//add block below
|
||||
// add block below
|
||||
await page.getByTestId('entry-1').locator('#event-block div').filter({ hasText: '1' }).press('Alt+B');
|
||||
await expect(page.getByPlaceholder('Block title')).toBeVisible();
|
||||
await page.getByPlaceholder(/block title/i).fill('block below');
|
||||
|
||||
//add block above
|
||||
// add block above
|
||||
await page.getByTestId('entry-1').locator('#event-block div').filter({ hasText: '1' }).press('Alt+Shift+B');
|
||||
await expect(page.getByTestId('entry-0').getByTestId('block__title')).toBeVisible();
|
||||
await page
|
||||
.getByPlaceholder(/block title/i)
|
||||
.first()
|
||||
.fill('block above');
|
||||
|
||||
await expect(page.getByTestId(/block__title/i).first()).toHaveValue('block above');
|
||||
await expect(page.getByTestId(/block__title/i).nth(2)).toHaveValue('block below');
|
||||
await expect(page.getByTestId('entry-1').getByTestId(/block__title/)).toHaveValue('test');
|
||||
});
|
||||
|
||||
test('Add delay', async ({ page }) => {
|
||||
|
||||
Vendored
+14
-14
@@ -31,7 +31,7 @@
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"countToEnd": false,
|
||||
"linkStart": null,
|
||||
"linkStart": false,
|
||||
"timeStrategy": "lock-end",
|
||||
"timeStart": 36000000,
|
||||
"timeEnd": 37200000,
|
||||
@@ -60,7 +60,7 @@
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"countToEnd": false,
|
||||
"linkStart": null,
|
||||
"linkStart": false,
|
||||
"timeStrategy": "lock-end",
|
||||
"timeStart": 37500000,
|
||||
"timeEnd": 38700000,
|
||||
@@ -89,7 +89,7 @@
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"countToEnd": false,
|
||||
"linkStart": null,
|
||||
"linkStart": false,
|
||||
"timeStrategy": "lock-end",
|
||||
"timeStart": 39000000,
|
||||
"timeEnd": 40200000,
|
||||
@@ -118,7 +118,7 @@
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"countToEnd": false,
|
||||
"linkStart": null,
|
||||
"linkStart": false,
|
||||
"timeStrategy": "lock-end",
|
||||
"timeStart": 40500000,
|
||||
"timeEnd": 41700000,
|
||||
@@ -147,7 +147,7 @@
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"countToEnd": false,
|
||||
"linkStart": null,
|
||||
"linkStart": false,
|
||||
"timeStrategy": "lock-end",
|
||||
"timeStart": 42000000,
|
||||
"timeEnd": 43200000,
|
||||
@@ -192,7 +192,7 @@
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"countToEnd": false,
|
||||
"linkStart": null,
|
||||
"linkStart": false,
|
||||
"timeStrategy": "lock-end",
|
||||
"timeStart": 47100000,
|
||||
"timeEnd": 48300000,
|
||||
@@ -221,7 +221,7 @@
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"countToEnd": false,
|
||||
"linkStart": null,
|
||||
"linkStart": false,
|
||||
"timeStrategy": "lock-end",
|
||||
"timeStart": 48600000,
|
||||
"timeEnd": 49800000,
|
||||
@@ -250,7 +250,7 @@
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"countToEnd": false,
|
||||
"linkStart": null,
|
||||
"linkStart": false,
|
||||
"timeStrategy": "lock-end",
|
||||
"timeStart": 50100000,
|
||||
"timeEnd": 51300000,
|
||||
@@ -279,7 +279,7 @@
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"countToEnd": false,
|
||||
"linkStart": null,
|
||||
"linkStart": false,
|
||||
"timeStrategy": "lock-end",
|
||||
"timeStart": 51600000,
|
||||
"timeEnd": 52800000,
|
||||
@@ -308,7 +308,7 @@
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"countToEnd": false,
|
||||
"linkStart": null,
|
||||
"linkStart": false,
|
||||
"timeStrategy": "lock-end",
|
||||
"timeStart": 53100000,
|
||||
"timeEnd": 54300000,
|
||||
@@ -353,7 +353,7 @@
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"countToEnd": false,
|
||||
"linkStart": null,
|
||||
"linkStart": false,
|
||||
"timeStrategy": "lock-end",
|
||||
"timeStart": 56100000,
|
||||
"timeEnd": 57300000,
|
||||
@@ -382,7 +382,7 @@
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"countToEnd": false,
|
||||
"linkStart": null,
|
||||
"linkStart": false,
|
||||
"timeStrategy": "lock-end",
|
||||
"timeStart": 57600000,
|
||||
"timeEnd": 58800000,
|
||||
@@ -411,7 +411,7 @@
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"countToEnd": false,
|
||||
"linkStart": null,
|
||||
"linkStart": false,
|
||||
"timeStrategy": "lock-end",
|
||||
"timeStart": 59100000,
|
||||
"timeEnd": 60300000,
|
||||
@@ -440,7 +440,7 @@
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"countToEnd": false,
|
||||
"linkStart": null,
|
||||
"linkStart": false,
|
||||
"timeStrategy": "lock-end",
|
||||
"timeStart": 60600000,
|
||||
"timeEnd": 61800000,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { EndAction, EntryCustomFields, MaybeNumber, MaybeString, TimerType, TimeStrategy, Trigger } from '../../index.js';
|
||||
import type { EndAction, EntryCustomFields, MaybeNumber, TimerType, TimeStrategy, Trigger } from '../../index.js';
|
||||
|
||||
export type EntryId = string;
|
||||
|
||||
@@ -43,7 +43,7 @@ export type OntimeEvent = OntimeBaseEvent & {
|
||||
endAction: EndAction;
|
||||
timerType: TimerType;
|
||||
countToEnd: boolean;
|
||||
linkStart: MaybeString; // ID of event to link to
|
||||
linkStart: boolean;
|
||||
timeStrategy: TimeStrategy;
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// runtime utils
|
||||
export { validatePlayback } from './src/validate-action/validatePlayback.js';
|
||||
export { isKnownTimerType, validateLinkStart, validateTimeStrategy } from './src/validate-events/validateEvent.js';
|
||||
export { isKnownTimerType, validateTimeStrategy } from './src/validate-events/validateEvent.js';
|
||||
export { calculateDuration, getLinkedTimes, validateTimes } from './src/validate-times/validateTimes.js';
|
||||
|
||||
// rundown utils
|
||||
@@ -78,7 +78,7 @@ export { validateEndAction, validateTimerType } from './src/validate-events/vali
|
||||
// feature business logic - rundown
|
||||
export { checkIsNow } from './src/date-utils/checkIsNow.js';
|
||||
export { checkIsNextDay } from './src/date-utils/checkIsNextDay.js';
|
||||
export { getTimeFromPrevious } from './src/date-utils/getTimeFromPrevious.js';
|
||||
export { getTimeFrom } from './src/date-utils/getTimeFrom.js';
|
||||
export { isNewLatest } from './src/date-utils/isNewLatest.js';
|
||||
|
||||
// feature business logic - spreadsheet import
|
||||
|
||||
+8
-12
@@ -1,11 +1,11 @@
|
||||
import { dayInMs, MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from './conversionUtils';
|
||||
import { getTimeFromPrevious } from './getTimeFromPrevious';
|
||||
import { getTimeFrom } from './getTimeFrom';
|
||||
|
||||
describe('getTimeFromPrevious', () => {
|
||||
describe('getTimeFrom', () => {
|
||||
it('returns the time elapsed (gap or overlap) from the previous', () => {
|
||||
const expected = 75600000 - 71700000; // current start - previousEnd
|
||||
expect(
|
||||
getTimeFromPrevious(
|
||||
getTimeFrom(
|
||||
{ timeStart: 21 * MILLIS_PER_HOUR, dayOffset: 0 },
|
||||
{ timeStart: 19 * MILLIS_PER_HOUR + 20 * MILLIS_PER_MINUTE, duration: 35 * MILLIS_PER_MINUTE, dayOffset: 0 },
|
||||
),
|
||||
@@ -14,22 +14,18 @@ describe('getTimeFromPrevious', () => {
|
||||
|
||||
it('accounts for partially overlapping events', () => {
|
||||
const expected = -1;
|
||||
expect(getTimeFromPrevious({ timeStart: 11, dayOffset: 0 }, { timeStart: 10, duration: 2, dayOffset: 0 })).toBe(
|
||||
expected,
|
||||
);
|
||||
expect(getTimeFrom({ timeStart: 11, dayOffset: 0 }, { timeStart: 10, duration: 2, dayOffset: 0 })).toBe(expected);
|
||||
});
|
||||
|
||||
it('accounts for events that are fully contained', () => {
|
||||
const expected = -6;
|
||||
expect(getTimeFromPrevious({ timeStart: 10, dayOffset: 0 }, { timeStart: 8, duration: 8, dayOffset: 0 })).toBe(
|
||||
expected,
|
||||
);
|
||||
expect(getTimeFrom({ timeStart: 10, dayOffset: 0 }, { timeStart: 8, duration: 8, dayOffset: 0 })).toBe(expected);
|
||||
});
|
||||
|
||||
it('fully overlapping events are the next day', () => {
|
||||
const expected = dayInMs - 2 * MILLIS_PER_HOUR;
|
||||
expect(
|
||||
getTimeFromPrevious(
|
||||
getTimeFrom(
|
||||
{ timeStart: 10 * MILLIS_PER_HOUR, dayOffset: 1 },
|
||||
{ timeStart: 10 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 0 },
|
||||
),
|
||||
@@ -39,7 +35,7 @@ describe('getTimeFromPrevious', () => {
|
||||
it('accounts for events that are the day after', () => {
|
||||
const expected = -MILLIS_PER_HOUR; // (previousEnd - currentStart);
|
||||
expect(
|
||||
getTimeFromPrevious(
|
||||
getTimeFrom(
|
||||
{ timeStart: 22 * MILLIS_PER_HOUR, dayOffset: 0 },
|
||||
{ timeStart: 20 * MILLIS_PER_HOUR, duration: 3 * MILLIS_PER_HOUR, dayOffset: 0 },
|
||||
),
|
||||
@@ -49,7 +45,7 @@ describe('getTimeFromPrevious', () => {
|
||||
it('accounts for events that cross midnight', () => {
|
||||
const expected = -MILLIS_PER_HOUR; // (previousEnd - currentStart);
|
||||
expect(
|
||||
getTimeFromPrevious(
|
||||
getTimeFrom(
|
||||
{ timeStart: 1 * MILLIS_PER_HOUR, dayOffset: 1 },
|
||||
{ timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR, dayOffset: 0 },
|
||||
),
|
||||
+2
-2
@@ -3,9 +3,9 @@ import type { OntimeEvent } from 'ontime-types';
|
||||
import { dayInMs } from './conversionUtils.js';
|
||||
|
||||
/**
|
||||
* Utility returns the gap from previous event
|
||||
* Utility returns the gap from a previous given event
|
||||
*/
|
||||
export function getTimeFromPrevious(
|
||||
export function getTimeFrom(
|
||||
current: Pick<OntimeEvent, 'timeStart' | 'dayOffset'>,
|
||||
previous: Pick<OntimeEvent, 'timeStart' | 'duration' | 'dayOffset'> | null,
|
||||
): number {
|
||||
@@ -1,19 +1,5 @@
|
||||
import type { MaybeString } from 'ontime-types';
|
||||
import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
|
||||
|
||||
/**
|
||||
* Check if a given value is a valid type linkStart, returns the fallback otherwise
|
||||
* linkStart can be a string (id of an event to link) or null (unlinked)
|
||||
* @param {MaybeString} maybeLinkStart
|
||||
* @returns {MaybeString}
|
||||
*/
|
||||
export function validateLinkStart(maybeLinkStart: unknown, fallback: MaybeString = null): MaybeString {
|
||||
if (typeof maybeLinkStart === 'string' || maybeLinkStart === null) {
|
||||
return maybeLinkStart as MaybeString;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a given value is a valid time strategy, returns the fallback otherwise
|
||||
* @param {TimeStrategy} maybeTimeStrategy
|
||||
|
||||
Generated
+6
-6
@@ -111,8 +111,8 @@ importers:
|
||||
specifier: ^5.0.28
|
||||
version: 5.0.28
|
||||
'@mantine/hooks':
|
||||
specifier: ^7.13.3
|
||||
version: 7.13.3(react@18.3.1)
|
||||
specifier: ^7.17.2
|
||||
version: 7.17.2(react@18.3.1)
|
||||
'@sentry/react':
|
||||
specifier: ^8.43.0
|
||||
version: 8.45.0(react@18.3.1)
|
||||
@@ -1653,10 +1653,10 @@ packages:
|
||||
resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
|
||||
'@mantine/hooks@7.13.3':
|
||||
resolution: {integrity: sha512-r2c+Z8CdvPKFeOwg6mSJmxOp9K/ave5ZFR7eJbgv4wQU8K1CAS5f5ven9K5uUX8Vf9B5dFnSaSgYp9UY3vOWTw==}
|
||||
'@mantine/hooks@7.17.2':
|
||||
resolution: {integrity: sha512-tbErVcGZu0E4dSmE6N0k6Tv1y9R3SQmmQgwqorcc+guEgKMdamc36lucZGlJnSGUmGj+WLUgELkEQ0asdfYBDA==}
|
||||
peerDependencies:
|
||||
react: ^18.2.0
|
||||
react: ^18.x || ^19.x
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
@@ -6456,7 +6456,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@mantine/hooks@7.13.3(react@18.3.1)':
|
||||
'@mantine/hooks@7.17.2(react@18.3.1)':
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
|
||||
Reference in New Issue
Block a user