diff --git a/apps/client/package.json b/apps/client/package.json index 998b03fe6..8d202a718 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -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", diff --git a/apps/client/src/common/hooks/useEventAction.ts b/apps/client/src/common/hooks/useEventAction.ts index e2e8b1064..41d9fc1b6 100644 --- a/apps/client/src/common/hooks/useEventAction.ts +++ b/apps/client/src/common/hooks/useEventAction.ts @@ -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)!; 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 { diff --git a/apps/client/src/common/utils/__tests__/eventsManager.test.ts b/apps/client/src/common/utils/__tests__/eventsManager.test.ts index f54b5d768..7cd263229 100644 --- a/apps/client/src/common/utils/__tests__/eventsManager.test.ts +++ b/apps/client/src/common/utils/__tests__/eventsManager.test.ts @@ -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, diff --git a/apps/client/src/features/rundown/BlockEmpty.tsx b/apps/client/src/features/rundown/BlockEmpty.tsx new file mode 100644 index 000000000..f828968e8 --- /dev/null +++ b/apps/client/src/features/rundown/BlockEmpty.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/apps/client/src/features/rundown/Empty.module.scss b/apps/client/src/features/rundown/Empty.module.scss new file mode 100644 index 000000000..7ed3e524c --- /dev/null +++ b/apps/client/src/features/rundown/Empty.module.scss @@ -0,0 +1,4 @@ +.empty { + padding-block: 1.5rem; + text-align: center; +} diff --git a/apps/client/src/features/rundown/Rundown.module.scss b/apps/client/src/features/rundown/Rundown.module.scss index 8573fbb23..a5da6dd8e 100644 --- a/apps/client/src/features/rundown/Rundown.module.scss +++ b/apps/client/src/features/rundown/Rundown.module.scss @@ -23,15 +23,6 @@ align-self: center; } -.alignCenter { - text-align: center; - flex-direction: column; - - .spaceTop { - margin-top: 1.5rem; - } -} - .spacer { min-height: 50vh; } diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index 392f9c4ae..7d65c04cf 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -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 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 (
@@ -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 ( - {isEditMode && (hasCursor || isFirst) && } -
- {isOntimeEvent(entry) &&
{eventIndex}
} -
- + {isEditMode && (hasCursor || isFirst) && ( + + )} + {isOntimeBlock(entry) ? ( + + {entry.events.length === 0 && ( + 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 ( + + {isEditMode && (hasNestedCursor || isFirstInGroup) && ( + + )} + +
+
{nestedRundownMeta.eventIndex}
+
+ +
+
+ {isEditMode && (hasNestedCursor || isLastInGroup) && ( + + )} +
+ ); + })} +
+ ) : ( +
+ {isOntimeEvent(entry) &&
{rundownMeta.eventIndex}
} +
+ +
-
- {isEditMode && (hasCursor || isLast) && } + )} + {isEditMode && (hasCursor || isLast) && } ); })} diff --git a/apps/client/src/features/rundown/RundownEmpty.tsx b/apps/client/src/features/rundown/RundownEmpty.tsx index 2de016e98..6ce8cb5b1 100644 --- a/apps/client/src/features/rundown/RundownEmpty.tsx +++ b/apps/client/src/features/rundown/RundownEmpty.tsx @@ -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 ( -
- -
diff --git a/apps/client/src/features/rundown/RundownEntry.tsx b/apps/client/src/features/rundown/RundownEntry.tsx index 70153796a..2dcf49001 100644 --- a/apps/client/src/features/rundown/RundownEntry.tsx +++ b/apps/client/src/features/rundown/RundownEntry.tsx @@ -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 ( - - {data.events.map((eventId) => { - return
{eventId}
; - })} -
- ); } else if (isOntimeDelay(data)) { return ; } diff --git a/apps/client/src/features/rundown/block-block/BlockBlock.module.scss b/apps/client/src/features/rundown/block-block/BlockBlock.module.scss index 92d8e6526..52830d6ea 100644 --- a/apps/client/src/features/rundown/block-block/BlockBlock.module.scss +++ b/apps/client/src/features/rundown/block-block/BlockBlock.module.scss @@ -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 { diff --git a/apps/client/src/features/rundown/block-block/BlockBlock.tsx b/apps/client/src/features/rundown/block-block/BlockBlock.tsx index 5f3d0abad..fb42bc84a 100644 --- a/apps/client/src/features/rundown/block-block/BlockBlock.tsx +++ b/apps/client/src/features/rundown/block-block/BlockBlock.tsx @@ -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) { const { data, hasCursor, children } = props; - + const [collapsed, setCollapsed] = useSessionStorage({ key: `block-${data.id}`, defaultValue: false }); const handleRef = useRef(null); const { @@ -35,16 +37,54 @@ export default function BlockBlock(props: PropsWithChildren) { transition, }; - const blockClasses = cx([style.block, hasCursor ? style.hasCursor : null]); + const binderColours = data.colour && getAccessibleColour(data.colour); return ( -
- - - - - -
{children}
+
+
+ + + +
+
+
+ + +
+
+
+
Start
+
{formatTime(data.startTime)}
+
+
+
End
+
{formatTime(data.endTime)}
+
+
+
Duration
+
{formatDuration(data.duration)}
+
+
+
Events
+
{data.numEvents}
+
+
+
+ {!collapsed && ( +
+ {children} +
+ )} +
); } diff --git a/apps/client/src/features/rundown/event-block/EventBlock.tsx b/apps/client/src/features/rundown/event-block/EventBlock.tsx index 71fab9222..2ddd307c2 100644 --- a/apps/client/src/features/rundown/event-block/EventBlock.tsx +++ b/apps/client/src/features/rundown/event-block/EventBlock.tsx @@ -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, }), }, { diff --git a/apps/client/src/features/rundown/event-block/EventBlockInner.tsx b/apps/client/src/features/rundown/event-block/EventBlockInner.tsx index ce821f33b..2b180f2e9 100644 --- a/apps/client/src/features/rundown/event-block/EventBlockInner.tsx +++ b/apps/client/src/features/rundown/event-block/EventBlockInner.tsx @@ -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; diff --git a/apps/client/src/features/rundown/event-editor/RundownEventEditor.tsx b/apps/client/src/features/rundown/event-editor/RundownEventEditor.tsx index 2def76469..54ccb71a3 100644 --- a/apps/client/src/features/rundown/event-editor/RundownEventEditor.tsx +++ b/apps/client/src/features/rundown/event-editor/RundownEventEditor.tsx @@ -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; diff --git a/apps/client/src/features/rundown/event-editor/composite/EventEditorTimes.tsx b/apps/client/src/features/rundown/event-editor/composite/EventEditorTimes.tsx index cd9593cc6..36c18b698 100644 --- a/apps/client/src/features/rundown/event-editor/composite/EventEditorTimes.tsx +++ b/apps/client/src/features/rundown/event-editor/composite/EventEditorTimes.tsx @@ -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; diff --git a/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx b/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx index 66eb7a596..42a675f5b 100644 --- a/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx +++ b/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx @@ -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 - + {showBlocks && ( + + )}
); } diff --git a/apps/client/src/features/rundown/rundown.utils.ts b/apps/client/src/features/rundown/rundown.utils.ts new file mode 100644 index 000000000..2ad16e3bd --- /dev/null +++ b/apps/client/src/features/rundown/rundown.utils.ts @@ -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 { + 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, +): Readonly { + 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; +} diff --git a/apps/client/src/features/rundown/time-input-flow/TimeInputFlow.tsx b/apps/client/src/features/rundown/time-input-flow/TimeInputFlow.tsx index 2cc711e5b..28680965b 100644 --- a/apps/client/src/features/rundown/time-input-flow/TimeInputFlow.tsx +++ b/apps/client/src/features/rundown/time-input-flow/TimeInputFlow.tsx @@ -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} > handleLink(!linkStart)}> diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx index 9fb86773d..0ef2a8536 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx @@ -22,7 +22,7 @@ function MakeStart({ getValue, row, table }: CellContext) 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; diff --git a/apps/client/src/views/timeline/timeline.utils.ts b/apps/client/src/views/timeline/timeline.utils.ts index 8c1068123..847d3a0c5 100644 --- a/apps/client/src/views/timeline/timeline.utils.ts +++ b/apps/client/src/views/timeline/timeline.utils.ts @@ -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; diff --git a/apps/server/src/api-integration/integration.controller.ts b/apps/server/src/api-integration/integration.controller.ts index cbab39b4d..585784872 100644 --- a/apps/server/src/api-integration/integration.controller.ts +++ b/apps/server/src/api-integration/integration.controller.ts @@ -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); diff --git a/apps/server/src/models/demoProject.ts b/apps/server/src/models/demoProject.ts index 94aa96c7b..de0527f1b 100644 --- a/apps/server/src/models/demoProject.ts +++ b/apps/server/src/models/demoProject.ts @@ -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, diff --git a/apps/server/src/models/eventsDefinition.ts b/apps/server/src/models/eventsDefinition.ts index 725aedac2..9775924e0 100644 --- a/apps/server/src/models/eventsDefinition.ts +++ b/apps/server/src/models/eventsDefinition.ts @@ -15,7 +15,7 @@ export const event: Omit = { 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 = { 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 = { @@ -45,11 +46,12 @@ export const block: Omit = { 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: {}, }; diff --git a/apps/server/src/services/__tests__/timerUtils.test.ts b/apps/server/src/services/__tests__/timerUtils.test.ts index bb6d05628..ec74c1898 100644 --- a/apps/server/src/services/__tests__/timerUtils.test.ts +++ b/apps/server/src/services/__tests__/timerUtils.test.ts @@ -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, diff --git a/apps/server/src/services/rundown-service/__mocks__/rundown.mocks.ts b/apps/server/src/services/rundown-service/__mocks__/rundown.mocks.ts index 7bc16bf75..570c8c958 100644 --- a/apps/server/src/services/rundown-service/__mocks__/rundown.mocks.ts +++ b/apps/server/src/services/rundown-service/__mocks__/rundown.mocks.ts @@ -9,6 +9,7 @@ const baseEvent = { const baseBlock = { type: SupportedEvent.Block, + events: [], }; /** diff --git a/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts b/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts index 5f296e0db..e92c74cee 100644 --- a/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts @@ -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, }, }); diff --git a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts index 399c36baf..4ef18fc41 100644 --- a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts @@ -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(); }); }); diff --git a/apps/server/src/services/rundown-service/__tests__/rundownCache.utils.test.ts b/apps/server/src/services/rundown-service/__tests__/rundownCache.utils.test.ts index b58a78a5c..6486c2836 100644 --- a/apps/server/src/services/rundown-service/__tests__/rundownCache.utils.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/rundownCache.utils.test.ts @@ -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(); + 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 }, ]; diff --git a/apps/server/src/services/rundown-service/delayUtils.ts b/apps/server/src/services/rundown-service/delayUtils.ts index 8d0d7b462..b5ed59e7c 100644 --- a/apps/server/src/services/rundown-service/delayUtils.ts +++ b/apps/server/src/services/rundown-service/delayUtils.ts @@ -68,7 +68,7 @@ export function apply(delayId: EntryId, rundown: Rundown): Rundown { lastEntry = { ...currentEntry }; if (shouldUnlink) { - currentEntry.linkStart = null; + currentEntry.linkStart = false; shouldUnlink = false; } diff --git a/apps/server/src/services/rundown-service/rundown.types.ts b/apps/server/src/services/rundown-service/rundown.types.ts new file mode 100644 index 000000000..5cc22809b --- /dev/null +++ b/apps/server/src/services/rundown-service/rundown.types.ts @@ -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; +}; diff --git a/apps/server/src/services/rundown-service/rundownCache.ts b/apps/server/src/services/rundown-service/rundownCache.ts index 99257e3fa..8a90a115d 100644 --- a/apps/server/src/services/rundown-service/rundownCache.ts +++ b/apps/server/src/services/rundown-service/rundownCache.ts @@ -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 = {}; - /** * 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(); - -/** - * Keep track of which custom fields are used. - * This will be handy for when we delete custom fields - */ -let assignedCustomFields: Record = {}; +export let customFieldChangelog: Record = {}; /** * Receives a rundown which will be processed and used as the new current rundown */ -export async function init(initialRundown: Rundown, customFields: Readonly) { +export async function init(initialRundown: Readonly, customFields: Readonly) { + // 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 = currentRundown, + customFields: Readonly = 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 { 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 { +export function getMetadata(): Readonly { if (isStale) { generate(); } return { - firstStart, - lastEnd, - totalDelay, - totalDuration, + ...rundownMetadata, revision: currentRundown.revision, }; } @@ -317,8 +256,8 @@ export function getEventOrder(): Readonly { } 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): 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): 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; } diff --git a/apps/server/src/services/rundown-service/rundownCache.utils.ts b/apps/server/src/services/rundown-service/rundownCache.utils.ts index 9eb6418cb..ac6263911 100644 --- a/apps/server/src/services/rundown-service/rundownCache.utils.ts +++ b/apps/server/src/services/rundown-service/rundownCache.utils.ts @@ -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, -): 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, + customFieldChangelog: Record, mutableEvent: OntimeEvent, assignedCustomFields: Record, ) { 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): 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) { + 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( + 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( + rundownMetadata: ProcessedRundownMetadata, + customFields: CustomFields, + customFieldChangelog: Record, + 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 }; +} diff --git a/apps/server/src/services/sheet-service/SheetService.ts b/apps/server/src/services/sheet-service/SheetService.ts index 4e51dbca3..4f9b5ff23 100644 --- a/apps/server/src/services/sheet-service/SheetService.ts +++ b/apps/server/src/services/sheet-service/SheetService.ts @@ -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'; diff --git a/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts b/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts index fbb7e2699..4a650c908 100644 --- a/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts +++ b/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts @@ -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', diff --git a/apps/server/src/utils/__tests__/parser.test.ts b/apps/server/src/utils/__tests__/parser.test.ts index 3c619ec03..5ba8eb270 100644 --- a/apps/server/src/utils/__tests__/parser.test.ts +++ b/apps/server/src/utils/__tests__/parser.test.ts @@ -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, }); }); }); diff --git a/apps/server/src/utils/__tests__/parserFunctions.test.ts b/apps/server/src/utils/__tests__/parserFunctions.test.ts index 8649b38a7..01a0b56f2 100644 --- a/apps/server/src/utils/__tests__/parserFunctions.test.ts +++ b/apps/server/src/utils/__tests__/parserFunctions.test.ts @@ -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', - }, - }, - }); - }); -}); diff --git a/apps/server/src/utils/parser.ts b/apps/server/src/utils/parser.ts index e1c35fefa..753904bf4 100644 --- a/apps/server/src/utils/parser.ts +++ b/apps/server/src/utils/parser.ts @@ -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 { 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(); diff --git a/e2e/tests/features/209-rundown-shortcuts.spec.ts b/e2e/tests/features/209-rundown-shortcuts.spec.ts index 1c69921c0..f76ac25d6 100644 --- a/e2e/tests/features/209-rundown-shortcuts.spec.ts +++ b/e2e/tests/features/209-rundown-shortcuts.spec.ts @@ -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 }) => { diff --git a/e2e/tests/fixtures/e2e-test-db.json b/e2e/tests/fixtures/e2e-test-db.json index c30468f97..67495d7e5 100644 --- a/e2e/tests/fixtures/e2e-test-db.json +++ b/e2e/tests/fixtures/e2e-test-db.json @@ -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, diff --git a/packages/types/src/definitions/core/OntimeEvent.type.ts b/packages/types/src/definitions/core/OntimeEvent.type.ts index 5fd073071..0db7a1f8f 100644 --- a/packages/types/src/definitions/core/OntimeEvent.type.ts +++ b/packages/types/src/definitions/core/OntimeEvent.type.ts @@ -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; diff --git a/packages/utils/index.ts b/packages/utils/index.ts index 9ef4e100f..46f1b6c96 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -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 diff --git a/packages/utils/src/date-utils/getTimeFromPrevious.test.ts b/packages/utils/src/date-utils/getTimeFrom.test.ts similarity index 76% rename from packages/utils/src/date-utils/getTimeFromPrevious.test.ts rename to packages/utils/src/date-utils/getTimeFrom.test.ts index a8633a2e0..8b2946ab3 100644 --- a/packages/utils/src/date-utils/getTimeFromPrevious.test.ts +++ b/packages/utils/src/date-utils/getTimeFrom.test.ts @@ -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 }, ), diff --git a/packages/utils/src/date-utils/getTimeFromPrevious.ts b/packages/utils/src/date-utils/getTimeFrom.ts similarity index 91% rename from packages/utils/src/date-utils/getTimeFromPrevious.ts rename to packages/utils/src/date-utils/getTimeFrom.ts index 4f22b35d5..d29efd30e 100644 --- a/packages/utils/src/date-utils/getTimeFromPrevious.ts +++ b/packages/utils/src/date-utils/getTimeFrom.ts @@ -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, previous: Pick | null, ): number { diff --git a/packages/utils/src/validate-events/validateEvent.ts b/packages/utils/src/validate-events/validateEvent.ts index f6601af5d..304d563fa 100644 --- a/packages/utils/src/validate-events/validateEvent.ts +++ b/packages/utils/src/validate-events/validateEvent.ts @@ -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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da0fb7ef9..f5f9e688c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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