diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts index 8c07779e4..e6eb4c2f1 100644 --- a/apps/client/src/common/hooks/useSocket.ts +++ b/apps/client/src/common/hooks/useSocket.ts @@ -151,7 +151,8 @@ export const useClock = createSelector((state: RuntimeStore) => ({ })); export const useNextFlag = createSelector((state: RuntimeStore) => ({ - nextFlag: state.nextFlag, + id: state.nextFlag?.id ?? null, + expectedStart: state.nextFlag?.expectedStart ?? null, })); /** Used by the progress bar components */ @@ -177,7 +178,6 @@ export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) = selectedEventIndex: state.runtime.selectedEventIndex, offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offsetAbs : state.runtime.offsetRel, - blockStartedAt: state.blockNow?.startedAt ?? null, blockExpectedEnd: state.blockNow?.expectedEnd ?? null, })); @@ -186,13 +186,13 @@ export const useTimelineStatus = createSelector((state: RuntimeStore) => ({ offset: state.runtime.offsetAbs, })); -export const useTimeUntilData = createSelector((state: RuntimeStore) => ({ - clock: state.clock, +export const useExpectedStartData = createSelector((state: RuntimeStore) => ({ offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offsetAbs : state.runtime.offsetRel, offsetMode: state.runtime.offsetMode, currentDay: state.eventNow?.dayOffset ?? 0, actualStart: state.runtime.actualStart, plannedStart: state.runtime.plannedStart, + clock: state.clock, })); export const useCurrentDay = createSelector((state: RuntimeStore) => ({ diff --git a/apps/client/src/common/utils/time.ts b/apps/client/src/common/utils/time.ts index c321ecbd9..be1799709 100644 --- a/apps/client/src/common/utils/time.ts +++ b/apps/client/src/common/utils/time.ts @@ -1,7 +1,7 @@ import { MaybeNumber, OntimeEvent, Settings, TimeFormat } from 'ontime-types'; import { - calculateTimeUntilStart, formatFromMillis, + getExpectedStart, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, @@ -9,7 +9,7 @@ import { import { FORMAT_12, FORMAT_24 } from '../../viewerConfig'; import { APP_SETTINGS } from '../api/constants'; -import { useTimeUntilData } from '../hooks/useSocket'; +import { useExpectedStartData } from '../hooks/useSocket'; import { ontimeQueryClient } from '../queryClient'; /** @@ -133,18 +133,24 @@ export function formatDuration(duration: number, hideSeconds = true): string { } /** - * * @param totalGap accumulated gap from the current event * @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event * @returns */ -export function useTimeUntilStart( +export function useTimeUntilExpectedStart( // typed like this to make it very clear what the data is - data: Pick & { + event: Pick | null, + state: { totalGap: number; isLinkedToLoaded: boolean; }, ): number { - const { offset, clock, currentDay, offsetMode, actualStart, plannedStart } = useTimeUntilData(); - return calculateTimeUntilStart({ ...data, currentDay, clock, offset, offsetMode, actualStart, plannedStart }); + const { offset, currentDay, offsetMode, actualStart, plannedStart, clock } = useExpectedStartData(); + if (event === null) return 0; + + const expectedStart = getExpectedStart( + { ...event }, + { ...state, currentDay, offset, offsetMode, actualStart, plannedStart }, + ); + return expectedStart - clock; } diff --git a/apps/client/src/features/operator/operator-event/OperatorEvent.tsx b/apps/client/src/features/operator/operator-event/OperatorEvent.tsx index 294f839a4..113ba3484 100644 --- a/apps/client/src/features/operator/operator-event/OperatorEvent.tsx +++ b/apps/client/src/features/operator/operator-event/OperatorEvent.tsx @@ -4,7 +4,7 @@ import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, millisToString } from 'ontime-uti import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; -import { formatDuration, useTimeUntilStart } from '../../../common/utils/time'; +import { formatDuration, useTimeUntilExpectedStart } from '../../../common/utils/time'; import RunningTime from '../../viewers/common/running-time/RunningTime'; import type { EditEvent, Subscribed } from '../operator.types'; @@ -162,7 +162,7 @@ interface TimeUntilProps { } function TimeUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }: TimeUntilProps) { // we isolate this to avoid unnecessary re-renders - const timeUntil = useTimeUntilStart({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }); + const timeUntil = useTimeUntilExpectedStart({ timeStart, delay, dayOffset }, { totalGap, isLinkedToLoaded }); const isDue = timeUntil < MILLIS_PER_SECOND; const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`; diff --git a/apps/client/src/features/overview/composite/TimeElements.tsx b/apps/client/src/features/overview/composite/TimeElements.tsx index 2d0f6aa62..f85c739c5 100644 --- a/apps/client/src/features/overview/composite/TimeElements.tsx +++ b/apps/client/src/features/overview/composite/TimeElements.tsx @@ -1,6 +1,14 @@ import { useMemo } from 'react'; -import { TbCalendar, TbCalendarClock, TbCalendarDown, TbCalendarStar, TbFlagDown, TbFlagStar } from 'react-icons/tb'; -import { isOntimeBlock, OntimeBlock, OntimeEvent, TimerPhase, TimerType } from 'ontime-types'; +import { + TbCalendarClock, + TbCalendarPin, + TbCalendarStar, + TbFlagPin, + TbFlagStar, + TbFolderPin, + TbFolderStar, +} from 'react-icons/tb'; +import { OntimeBlock, OntimeEvent, TimerPhase, TimerType } from 'ontime-types'; import { isPlaybackActive, millisToString } from 'ontime-utils'; import Tooltip from '../../../common/components/tooltip/Tooltip'; @@ -15,7 +23,7 @@ import { import { useEntry } from '../../../common/hooks-query/useRundown'; import { getOffsetState, getOffsetText } from '../../../common/utils/offset'; import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils'; -import { formatTime, useTimeUntilStart } from '../../../common/utils/time'; +import { formatTime } from '../../../common/utils/time'; import { calculateEndAndDaySpan, formattedTime } from '../overview.utils'; import { OverUnder, TimeColumn } from './TimeLayout'; @@ -36,7 +44,7 @@ export function StartTimes() {
Start
- } /> + } /> {plannedStartText}
@@ -47,7 +55,7 @@ export function StartTimes() {
End
- } /> + } /> {maybePlannedDaySpan > 0 ? ( - Group -
- } /> - {timerPlaceholder} -
-
- } /> - {timerPlaceholder} -
-
- ); - } + // the group end time dose not encode any day offsets + const plannedGroupEnd = group && group.timeStart !== null ? group.timeStart + group.duration - clock : null; + const plannedTimeUntilGroupEnd = formattedTime(plannedGroupEnd, 3, TimerType.CountDown); - const remainingBlockDuration = (() => { - if (blockStartedAt === null || !entry) return timerPlaceholder; - if (!isOntimeBlock(entry)) return timerPlaceholder; - return formattedTime(blockStartedAt + entry.duration - clock, 3, TimerType.CountDown); - })(); + const expectedGroupEnd = blockExpectedEnd !== null ? blockExpectedEnd - clock : null; + const expectedTimeUntilGroupEnd = formattedTime(expectedGroupEnd, 3, TimerType.CountDown); - const timeUntilBlockEnd = (() => { - if (blockExpectedEnd === null) return timerPlaceholder; - return formattedTime(blockExpectedEnd - clock, 3, TimerType.CountDown); - })(); - - const groupTitle = (entry as OntimeBlock | null)?.title || 'Group'; + const groupTitle = group?.title ?? null; return (
- {groupTitle} + {`${groupTitle ? groupTitle : 'Group'} `}
- } /> - {remainingBlockDuration} + } /> + {plannedTimeUntilGroupEnd}
- } /> - {timeUntilBlockEnd} + } /> + {expectedTimeUntilGroupEnd}
); @@ -139,50 +128,27 @@ function GroupTimes() { function FlagTimes() { const { clock } = useClock(); - const { nextFlag } = useNextFlag(); - const entry = useEntry(nextFlag?.id ?? null); + const { id, expectedStart } = useNextFlag(); + const entry = useEntry(id) as OntimeEvent | null; - // TODO(v4): can we make a good approximation of time until next flag? - const timeUntil = useTimeUntilStart({ - timeStart: nextFlag?.start ?? 0, - delay: 0, - dayOffset: 0, - totalGap: 0, - isLinkedToLoaded: true, - }); + const plannedFlagStart = entry ? entry.timeStart - clock : null; + const plannedTimeUntilDisplay = formattedTime(plannedFlagStart, 3, TimerType.CountDown); - if (!nextFlag) { - return ( -
- Flag -
- } /> - {timerPlaceholder} -
-
- } /> - {timerPlaceholder} -
-
- ); - } + const expectedTimeUntil = expectedStart !== null ? expectedStart - clock : null; + const expectedTimeUntilDisplay = formattedTime(expectedTimeUntil, 3, TimerType.CountDown); - const muted = nextFlag === null; - const flagTitle = (entry as OntimeEvent | null)?.title || 'Flag'; - const timeToNextFlag = nextFlag.start - clock; - const display = millisToString(timeToNextFlag, { fallback: timerPlaceholder }); - const timeUntilDisplay = millisToString(timeUntil, { fallback: timerPlaceholder }); + const title = entry?.title ?? null; return (
- {flagTitle} + {`${title ? title : 'Flag'} `}
- } /> - {display} + } /> + {plannedTimeUntilDisplay}
} /> - {timeUntilDisplay} + {expectedTimeUntilDisplay}
); diff --git a/apps/client/src/features/rundown/rundown-block/RundownBlock.tsx b/apps/client/src/features/rundown/rundown-block/RundownBlock.tsx index f6be80ee3..48e2434cc 100644 --- a/apps/client/src/features/rundown/rundown-block/RundownBlock.tsx +++ b/apps/client/src/features/rundown/rundown-block/RundownBlock.tsx @@ -30,6 +30,7 @@ interface RundownBlockProps { onCollapse: (collapsed: boolean, groupId: EntryId) => void; } +//TODO: the block should maybe include a multiple day indicator export default function RundownBlock({ data, hasCursor, collapsed, onCollapse }: RundownBlockProps) { const handleRef = useRef(null); const { clone, ungroup, deleteEntry } = useEntryActions(); diff --git a/apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.tsx b/apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.tsx index 0f8f06d13..a6d02b7e2 100644 --- a/apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.tsx +++ b/apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.tsx @@ -6,7 +6,7 @@ import Tooltip from '../../../../common/components/tooltip/Tooltip'; import { usePlayback } from '../../../../common/hooks/useSocket'; import useReport from '../../../../common/hooks-query/useReport'; import { cx } from '../../../../common/utils/styleUtils'; -import { formatDuration, useTimeUntilStart } from '../../../../common/utils/time'; +import { formatDuration, useTimeUntilExpectedStart } from '../../../../common/utils/time'; import style from './RundownEventChip.module.scss'; @@ -76,7 +76,7 @@ interface EventUntilProps { function EventUntil(props: EventUntilProps) { const { timeStart, delay, dayOffset, totalGap, isLinkedToLoaded } = props; - const timeUntil = useTimeUntilStart({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }); + const timeUntil = useTimeUntilExpectedStart({ timeStart, delay, dayOffset }, { totalGap, isLinkedToLoaded }); const isDue = timeUntil < MILLIS_PER_SECOND; const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`; diff --git a/apps/server/src/api-data/rundown/rundown.dao.ts b/apps/server/src/api-data/rundown/rundown.dao.ts index 0466dc3bd..361fd5121 100644 --- a/apps/server/src/api-data/rundown/rundown.dao.ts +++ b/apps/server/src/api-data/rundown/rundown.dao.ts @@ -723,11 +723,11 @@ export function processRundown( // 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; const blockEvents: EntryId[] = []; + processedEntry.duration = 0; // check if the block contains nested entries for (let j = 0; j < processedEntry.entries.length; j++) { @@ -739,10 +739,7 @@ export function processRundown( } blockEvents.push(nestedEntry.id); - const { processedData: processedNestedData, processedEntry: processedNestedEntry } = process( - nestedEntry, - processedEntry.id, - ); + const { processedEntry: processedNestedEntry } = process(nestedEntry, processedEntry.id); // we dont extract metadata of skipped events, // if this is not a playable event there is nothing else to do @@ -757,12 +754,14 @@ export function processRundown( } // lastEntry is the event with the latest end time - blockEndTime = processedNestedData.lastEnd; - totalBlockDuration += processedNestedEntry.duration; + blockEndTime = processedNestedEntry.timeEnd; + if (j > 0) { + processedEntry.duration += processedNestedEntry.gap; + } + processedEntry.duration = processedEntry.duration + processedNestedEntry.duration; } // update block metadata - processedEntry.duration = totalBlockDuration; processedEntry.timeStart = blockStartTime; processedEntry.timeEnd = blockEndTime; processedEntry.isFirstLinked = isFirstLinked; diff --git a/apps/server/src/services/runtime-service/RuntimeService.ts b/apps/server/src/services/runtime-service/RuntimeService.ts index 3fab4fd03..05b1aabfc 100644 --- a/apps/server/src/services/runtime-service/RuntimeService.ts +++ b/apps/server/src/services/runtime-service/RuntimeService.ts @@ -715,14 +715,14 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert getForceUpdate(RuntimeService.previousRuntimeUpdate, state.clock)) && !deepEqual(RuntimeService.previousState?.runtime, state.runtime); - /** - * the currentBlock object has the potential to tick on expected end - * TODO: the value shows up one tick to late - */ + // TODO: the value shows up one tick to late const shouldBlockUpdate = !deepEqual(RuntimeService?.previousState.blockNow, state.blockNow) || RuntimeService?.previousState.blockNext !== state.blockNext; + // TODO: the value shows up one tick to late + const shouldNextFlagUpdate = !deepEqual(RuntimeService?.previousState?.nextFlag, state.nextFlag); + /** * Many other values are calculated based on the clock * so if any of them are updated we also need to send the clock @@ -755,9 +755,9 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert RuntimeService.previousState.blockNext = state.blockNext; } - if (RuntimeService.previousState?.nextFlag !== state.nextFlag) { + if (shouldNextFlagUpdate) { batch.add('nextFlag', state.nextFlag); - RuntimeService.previousState.nextFlag = state.nextFlag; + RuntimeService.previousState.nextFlag = structuredClone(state.nextFlag); } if (hasImmediateChanges) { diff --git a/apps/server/src/services/timerUtils.ts b/apps/server/src/services/timerUtils.ts index 9cc947c94..9f119abe3 100644 --- a/apps/server/src/services/timerUtils.ts +++ b/apps/server/src/services/timerUtils.ts @@ -1,67 +1,12 @@ -import { isOntimeEvent, MaybeNumber, OffsetMode, OntimeBlock, Rundown, TimerPhase } from 'ontime-types'; -import { calculateTimeUntilStart, dayInMs, getLastEventNormal, isPlaybackActive } from 'ontime-utils'; +import { MaybeNumber, TimerPhase } from 'ontime-types'; +import { dayInMs, isPlaybackActive } from 'ontime-utils'; import type { RuntimeState } from '../stores/runtimeState.js'; -import { shouldCrashDev } from '../utils/development.js'; /** * handle events that span over midnight */ export const normaliseEndTime = (start: number, end: number) => (end < start ? end + dayInMs : end); -/** - * Calculates the expected time of the group to end. - * Should only be called if a block is running - * TODO: take a look at how it handles relative offset mode - */ -export function getExpectedBlockFinish(state: RuntimeState, rundown: Rundown): MaybeNumber { - const { blockNow, eventNow, timer, clock } = state; - - if (blockNow === null) return null; - // if the group doesn't have a start time there is no end time either - if (blockNow.startedAt === null) return null; - if (eventNow === null) return null; - if (timer.current === null) return null; - - const { entries } = rundown; - const orderInBlock = (entries[blockNow.id] as OntimeBlock).entries; - - const indexInBlock = orderInBlock.findIndex((id) => eventNow.id === id); - shouldCrashDev(indexInBlock < 0, 'Running event is not in current block'); - - if (indexInBlock === orderInBlock.length - 1) return timer.expectedFinish; - - let totalGap = 0; - let isLinkedToLoaded = true; - - for (let i = indexInBlock + 1; i < orderInBlock.length; i++) { - const entry = entries[orderInBlock[i]]; - if (isOntimeEvent(entry)) { - totalGap += entry.gap; - isLinkedToLoaded = isLinkedToLoaded && entry.linkStart; - } - } - const { lastEvent } = getLastEventNormal(rundown.entries, orderInBlock); - if (!lastEvent) return null; - - const { offsetMode, offsetAbs, offsetRel, plannedStart, actualStart } = state.runtime; - - const timeUntilLastEvent = calculateTimeUntilStart({ - timeStart: lastEvent.timeStart, - dayOffset: lastEvent.dayOffset, - delay: lastEvent.delay, - currentDay: eventNow.dayOffset, - totalGap, - isLinkedToLoaded, - clock, - offsetMode, - offset: offsetMode === OffsetMode.Absolute ? offsetAbs : offsetRel, - plannedStart, - actualStart, - }); - - return clock + timeUntilLastEvent + lastEvent.duration; -} - /** * Calculates expected finish time of a running timer * @param {RuntimeState} state runtime state @@ -221,17 +166,6 @@ export function getRuntimeOffset(state: RuntimeState): { offsetAbs: number; offs return { offsetAbs: offset, offsetRel }; } -/** - * Calculates the expected end of the rundown - */ -export function getExpectedEnd(state: RuntimeState): MaybeNumber { - // there is no expected end if we havent started - if (state.runtime.actualStart === null || state.runtime.plannedEnd === null) { - return null; - } - return state.runtime.plannedEnd - state.runtime.offsetAbs + state._rundown.totalDelay; -} - /** * Checks running timer to see which phase it currently is in * @param state diff --git a/apps/server/src/stores/__mocks__/runtimeState.mocks.ts b/apps/server/src/stores/__mocks__/runtimeState.mocks.ts index 62427506e..53a34c6df 100644 --- a/apps/server/src/stores/__mocks__/runtimeState.mocks.ts +++ b/apps/server/src/stores/__mocks__/runtimeState.mocks.ts @@ -40,6 +40,9 @@ const baseState: RuntimeState = { _rundown: { totalDelay: 0, }, + _block: null, + _end: null, + _flag: null, }; export function makeRuntimeStateData(patch?: Partial): RuntimeState { diff --git a/apps/server/src/stores/__tests__/runtimeState.test.ts b/apps/server/src/stores/__tests__/runtimeState.test.ts index c5034617d..182b4fa77 100644 --- a/apps/server/src/stores/__tests__/runtimeState.test.ts +++ b/apps/server/src/stores/__tests__/runtimeState.test.ts @@ -9,13 +9,14 @@ import { clearState, getState, load, - loadBlock, + loadBlockFlagAndEnd, pause, roll, start, stop, } from '../runtimeState.js'; import { rundownCache } from '../../api-data/rundown/rundown.dao.js'; +import { RundownMetadata } from '../../api-data/rundown/rundown.types.js'; const mockEvent = { type: 'event', @@ -388,7 +389,9 @@ describe('loadBlock', () => { eventNow: rundown.entries[11], } as unknown as RuntimeState; - loadBlock(rundown, state); + const metadata = { playableEventOrder: ['0', '11', '3'], flags: ['1'] } as RundownMetadata; + + loadBlockFlagAndEnd(rundown, metadata, 2, state); expect(state).toMatchObject({ blockNow: { id: rundown.entries[1].id, startedAt: null }, @@ -413,7 +416,9 @@ describe('loadBlock', () => { eventNow: rundown.entries[22], } as RuntimeState; - loadBlock(rundown, state); + const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata; + + loadBlockFlagAndEnd(rundown, metadata, 1, state); expect(state).toMatchObject({ blockNow: { id: rundown.entries[2].id, startedAt: null }, @@ -441,7 +446,9 @@ describe('loadBlock', () => { eventNow: rundown.entries[0], } as RuntimeState; - loadBlock(rundown, state); + const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata; + + loadBlockFlagAndEnd(rundown, metadata, 1, state); expect(state).toMatchObject({ blockNow: null, @@ -464,7 +471,10 @@ describe('loadBlock', () => { eventNow: rundown.entries[2], } as RuntimeState; - loadBlock(rundown, state); + const metadata = { playableEventOrder: ['1', '2'], flags: ['1'] } as RundownMetadata; + + loadBlockFlagAndEnd(rundown, metadata, 0, state); + expect(state).toMatchObject({ blockNow: { id: rundown.entries[0].id, startedAt: 123 }, eventNow: rundown.entries[2], @@ -485,7 +495,9 @@ describe('loadBlock', () => { eventNow: rundown.entries[0], } as RuntimeState; - loadBlock(rundown, state); + const metadata = { playableEventOrder: ['0', '1'], flags: ['1'] } as RundownMetadata; + + loadBlockFlagAndEnd(rundown, metadata, 0, state); expect(state).toMatchObject({ blockNow: null, diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index 1677c6cc3..c3fa806cf 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -1,10 +1,12 @@ import { CurrentBlockState, - isOntimeBlock, + EntryMetaData, isOntimeEvent, MaybeNumber, MaybeString, OffsetMode, + OntimeBlock, + OntimeEvent, PlayableEvent, Playback, Rundown, @@ -12,31 +14,31 @@ import { runtimeStorePlaceholder, TimerPhase, TimerState, - UpcomingEntry, } from 'ontime-types'; -import { calculateDuration, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils'; +import { + calculateDuration, + checkIsNow, + dayInMs, + getExpectedStart, + getLastEventNormal, + isPlaybackActive, +} from 'ontime-utils'; import { timeNow } from '../utils/time.js'; import type { RestorePoint } from '../services/RestoreService.js'; -import { - getCurrent, - getExpectedBlockFinish, - getExpectedEnd, - getExpectedFinish, - getRuntimeOffset, - getTimerPhase, -} from '../services/timerUtils.js'; +import { getCurrent, getExpectedFinish, getRuntimeOffset, getTimerPhase } from '../services/timerUtils.js'; import { loadRoll, normaliseRollStart } from '../services/rollUtils.js'; import { timerConfig } from '../setup/config.js'; import { RundownMetadata } from '../api-data/rundown/rundown.types.js'; import { getPlayableIndexFromTimedIndex } from '../api-data/rundown/rundown.utils.js'; -import { getCurrentRundown } from '../api-data/rundown/rundown.dao.js'; + +type ExpectedMetadata = { event: OntimeEvent; accumulatedGap: number; isLinkedToLoaded: boolean } | null; export type RuntimeState = { clock: number; // realtime clock blockNow: CurrentBlockState | null; blockNext: MaybeString; - nextFlag: UpcomingEntry | null; + nextFlag: EntryMetaData | null; eventNow: PlayableEvent | null; eventNext: PlayableEvent | null; runtime: Runtime; @@ -50,6 +52,9 @@ export type RuntimeState = { _rundown: { totalDelay: number; // this value comes from rundown service }; + _block: ExpectedMetadata; + _flag: ExpectedMetadata; + _end: ExpectedMetadata; }; const runtimeState: RuntimeState = { @@ -69,6 +74,9 @@ const runtimeState: RuntimeState = { _rundown: { totalDelay: 0, }, + _block: null, + _flag: null, + _end: null, }; export function getState(): Readonly { @@ -96,6 +104,7 @@ export function clearEventData() { runtimeState.runtime.expectedEnd = null; runtimeState.runtime.selectedEventIndex = null; + //TODO: is there any ExpectedMetadata stuff we need to clear here if (runtimeState.blockNow) runtimeState.blockNow.expectedEnd = null; runtimeState.timer.playback = Playback.Stop; @@ -115,12 +124,16 @@ export function clearState() { runtimeState.blockNow = null; runtimeState.blockNext = null; + runtimeState._block = null; + runtimeState.nextFlag = null; + runtimeState._flag = null; runtimeState.runtime.offsetAbs = 0; runtimeState.runtime.offsetRel = 0; runtimeState.runtime.actualStart = null; runtimeState.runtime.expectedEnd = null; + runtimeState._end = null; runtimeState.runtime.selectedEventIndex = null; runtimeState.timer.playback = Playback.Stop; @@ -171,7 +184,7 @@ export function updateRundownData(rundownData: RundownData) { runtimeState.runtime.plannedStart = rundownData.firstStart; runtimeState.runtime.plannedEnd = rundownData.firstStart === null ? null : rundownData.firstStart + rundownData.totalDuration; - runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState); + getExpectedTimes(); } /** @@ -199,8 +212,7 @@ export function load( // load events in memory along with their data loadNow(rundown, metadata, eventIndex); loadNext(rundown, metadata, eventIndex); - loadBlock(rundown); - loadNextFlag(eventIndex, rundown, metadata); + loadBlockFlagAndEnd(rundown, metadata, eventIndex); // update state runtimeState.timer.playback = Playback.Armed; @@ -217,7 +229,7 @@ export function load( const { offsetAbs, offsetRel } = getRuntimeOffset(runtimeState); runtimeState.runtime.offsetAbs = offsetAbs; runtimeState.runtime.offsetRel = offsetRel; - runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState); + getExpectedTimes(); } if (typeof initialData.blockStartAt === 'number' && runtimeState.blockNow) { runtimeState.blockNow.startedAt = initialData.blockStartAt; @@ -341,8 +353,7 @@ export function updateAll(rundown: Rundown, metadata: RundownMetadata) { loadNow(rundown, metadata, eventNowIndex >= 0 ? eventNowIndex : undefined); loadNext(rundown, metadata, eventNowIndex >= 0 ? eventNowIndex : undefined); updateLoaded(runtimeState.eventNow ?? undefined); - loadBlock(rundown); - loadNextFlag(eventNowIndex, rundown, metadata); + loadBlockFlagAndEnd(rundown, metadata, eventNowIndex); } export function start(state: RuntimeState = runtimeState): boolean { @@ -397,7 +408,7 @@ export function start(state: RuntimeState = runtimeState): boolean { } } - state.runtime.expectedEnd = state.runtime.plannedEnd - state.runtime.offsetAbs; + getExpectedTimes(); return true; } @@ -460,7 +471,7 @@ export function addTime(amount: number) { const { offsetAbs, offsetRel } = getRuntimeOffset(runtimeState); runtimeState.runtime.offsetAbs = offsetAbs; runtimeState.runtime.offsetRel = offsetRel; - runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState); + getExpectedTimes(); return true; } @@ -507,7 +518,7 @@ export function update(): UpdateResult { const { offsetAbs, offsetRel } = getRuntimeOffset(runtimeState); runtimeState.runtime.offsetAbs = offsetAbs; runtimeState.runtime.offsetRel = offsetRel; - runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState); + // runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState); const finishedNow = Boolean(runtimeState._timer.forceFinish) || @@ -520,10 +531,7 @@ export function update(): UpdateResult { runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState); } - if (runtimeState.blockNow) { - const expectedBlockFinish = getExpectedBlockFinish(runtimeState, getCurrentRundown()); - runtimeState.blockNow.expectedEnd = expectedBlockFinish; - } + getExpectedTimes(); return { hasTimerFinished: finishedNow, hasSecondaryTimerFinished: false }; @@ -632,7 +640,7 @@ export function roll( // load events in memory along with their data loadNow(rundown, metadata, index); loadNext(rundown, metadata, index); - loadBlock(rundown); + loadBlockFlagAndEnd(rundown, metadata, index); // update roll state runtimeState.timer.playback = Playback.Roll; @@ -690,72 +698,152 @@ export function roll( } /** - * handle block loading, not for use outside of runtimeState + * calculates and sets values directly in state + * - runtime.expectedEnd + * - blockNow.expectedEnd + * - nextFlag.expectedStart */ -export function loadBlock(rundown: Rundown, state = runtimeState) { - // we need a loaded event to have a block - if (state.eventNow === null) { - state.blockNow = null; - state.blockNext = null; - return; +function getExpectedTimes(state = runtimeState) { + const { offsetMode, offsetAbs, offsetRel, plannedStart, actualStart } = state.runtime; + const { eventNow } = state; + + if (!eventNow) return; + + state.runtime.expectedEnd = null; + if (state.blockNow) { + state.blockNow.expectedEnd = null; + const { _block } = state; + if (state.blockNow.startedAt !== null && _block !== null) { + const { event, accumulatedGap, isLinkedToLoaded } = _block; + const expectedStart = getExpectedStart(event, { + currentDay: eventNow.dayOffset, + totalGap: accumulatedGap, + isLinkedToLoaded, + offsetMode, + offset: offsetMode === OffsetMode.Absolute ? offsetAbs : offsetRel, + plannedStart, + actualStart, + }); + state.blockNow.expectedEnd = expectedStart + event.duration; + } } + if (state.nextFlag) { + state.nextFlag.expectedStart = null; + const { _flag } = state; + if (_flag) { + const { event, accumulatedGap, isLinkedToLoaded } = _flag; + const expectedStart = getExpectedStart(event, { + currentDay: eventNow.dayOffset, + totalGap: accumulatedGap, + isLinkedToLoaded, + offsetMode, + offset: offsetMode === OffsetMode.Absolute ? offsetAbs : offsetRel, + plannedStart, + actualStart, + }); + state.nextFlag.expectedStart = expectedStart; + } + } + + if (state._end) { + const { event, accumulatedGap, isLinkedToLoaded } = state._end; + const expectedStart = getExpectedStart(event, { + currentDay: eventNow.dayOffset, + totalGap: accumulatedGap, + isLinkedToLoaded, + offsetMode, + offset: offsetMode === OffsetMode.Absolute ? offsetAbs : offsetRel, + plannedStart, + actualStart, + }); + state.runtime.expectedEnd = expectedStart + event.duration; + } else { + state.runtime.expectedEnd = null; + } +} + +export function loadBlockFlagAndEnd( + rundown: Rundown, + metadata: RundownMetadata, + currentIndex: MaybeNumber, + state = runtimeState, +) { + if (currentIndex === null) return resetMetaData(); + if (state.eventNow === null) return resetMetaData(); + const currentBlockId = state.eventNow.parent; + const flagsPresent = metadata.flags.length !== 0; - // look for potential next block - let foundEventNow = false; - for (const id of rundown.order) { - if (foundEventNow && isOntimeBlock(rundown.entries[id])) { - state.blockNext = id; // the id is set here, the start time is set in other placed that handel starting events - break; - } - if (id === state.eventNow.id) { - foundEventNow = true; - continue; + const { playableEventOrder } = metadata; + const { entries } = rundown; + + const orderInBlock = currentBlockId ? (entries[currentBlockId] as OntimeBlock).entries : null; + const lastEventInGroup = orderInBlock ? getLastEventNormal(rundown.entries, orderInBlock).lastEvent : null; + + // if we don't have a any flags in the rundown then no need to look for it + let foundFlag = !flagsPresent; + let foundNextGroup = false; + // if we don't have a last event for the group there is no need to find its end time + let foundGroupEnd = lastEventInGroup === null; + + let accumulatedGap = 0; + let isLinkedToLoaded = true; + + for (let idx = currentIndex; idx < playableEventOrder.length; idx++) { + const entry = entries[playableEventOrder[idx]]; + + if (isOntimeEvent(entry)) { + if (idx !== currentIndex) { + // we only accumulate data after the loaded event + accumulatedGap += entry.gap; + isLinkedToLoaded = isLinkedToLoaded && entry.linkStart; + + // and the loaded event is not allowed to be the next flag + if (!foundFlag && metadata.flags.includes(entry.id)) { + foundFlag = true; + state.nextFlag = { id: entry.id, actualStart: null, expectedStart: null, expectedEnd: null }; + state._flag = { event: entry, isLinkedToLoaded, accumulatedGap }; + } + } + + if (!foundGroupEnd && entry.id === lastEventInGroup?.id) { + foundGroupEnd = true; + state._block = { event: lastEventInGroup, isLinkedToLoaded, accumulatedGap }; + } + + if (!foundNextGroup && entry.parent !== currentBlockId) { + foundNextGroup = true; + state.blockNext = entry.parent; + } } } - // not inside a block + const lastID = playableEventOrder.at(-1); + const lastEvent = lastID ? (entries[lastID] as OntimeEvent) : null; + if (lastEvent) { + state._end = { event: lastEvent, isLinkedToLoaded, accumulatedGap }; + } + + if (!foundFlag) state.nextFlag = null; + if (currentBlockId === null) { state.blockNow = null; - return; - } - - //we went into a new block - and it is different from the one we might have come from - if ((state.blockNow != null && state.blockNow.id != currentBlockId) || state.blockNow == null) { - state.blockNow = { id: currentBlockId, startedAt: null, expectedEnd: null }; // the id is set here, the start time is set when starting events + } else if ((state.blockNow != null && state.blockNow.id != currentBlockId) || state.blockNow == null) { + // we went into a new block - and it is different from the one we might have come from + // the id is set here, the start time is set when starting events + state.blockNow = { id: currentBlockId, startedAt: null, expectedEnd: null }; } } -/** - * find and load the next flag from the currently loaded event - */ -export function loadNextFlag(currentIndex: number, rundown: Rundown, metadata: RundownMetadata) { - runtimeState.nextFlag = null; - - if (metadata.flags.length === 0) { - return; - } - - // we are in the last event - if (currentIndex + 1 >= metadata.timedEventOrder.length) { - return; - } - - for (let i = currentIndex + 1; i < metadata.timedEventOrder.length; i++) { - const entryId = metadata.timedEventOrder[i]; - if (metadata.flags.includes(entryId)) { - const event = rundown.entries[entryId]; - if (!event || !isOntimeEvent(event)) { - continue; - } - runtimeState.nextFlag = { id: event.id, start: event.timeStart }; - return; - } - } - - return null; -} +const resetMetaData = (state = runtimeState) => { + state.blockNow = null; + state.blockNext = null; + state._block = null; + state.nextFlag = null; + state._flag = null; + state._end = null; +}; export function setOffsetMode(mode: OffsetMode) { runtimeState.runtime.offsetMode = mode; diff --git a/packages/types/src/definitions/runtime/CurrentBlockState.type.ts b/packages/types/src/definitions/runtime/CurrentBlockState.type.ts index 17e53269d..fca199d76 100644 --- a/packages/types/src/definitions/runtime/CurrentBlockState.type.ts +++ b/packages/types/src/definitions/runtime/CurrentBlockState.type.ts @@ -11,3 +11,10 @@ export type UpcomingEntry = { id: EntryId; start: number; }; + +export type EntryMetaData = { + id: EntryId; + actualStart: MaybeNumber; + expectedStart: MaybeNumber; + expectedEnd: MaybeNumber; +}; diff --git a/packages/types/src/definitions/runtime/RuntimeStore.type.ts b/packages/types/src/definitions/runtime/RuntimeStore.type.ts index 2eb85b164..2d8eef8b8 100644 --- a/packages/types/src/definitions/runtime/RuntimeStore.type.ts +++ b/packages/types/src/definitions/runtime/RuntimeStore.type.ts @@ -1,7 +1,7 @@ import type { MaybeString } from '../../utils/utils.type.js'; import type { OntimeEvent } from '../core/OntimeEntry.js'; import type { SimpleTimerState } from './AuxTimer.type.js'; -import type { CurrentBlockState, UpcomingEntry } from './CurrentBlockState.type.js'; +import type { CurrentBlockState, EntryMetaData } from './CurrentBlockState.type.js'; import type { MessageState } from './MessageControl.type.js'; import type { Runtime } from './Runtime.type.js'; import type { TimerState } from './TimerState.type.js'; @@ -22,7 +22,7 @@ export type RuntimeStore = { blockNow: CurrentBlockState | null; blockNext: MaybeString; - nextFlag: UpcomingEntry | null; + nextFlag: EntryMetaData | null; // extra timers auxtimer1: SimpleTimerState; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 076e0d5c8..82ad05399 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -100,7 +100,7 @@ export { OffsetMode } from './definitions/runtime/Runtime.type.js'; export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js'; export { runtimeStorePlaceholder } from './definitions/runtime/RuntimeStore.js'; export { type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js'; -export type { CurrentBlockState, UpcomingEntry } from './definitions/runtime/CurrentBlockState.type.js'; +export type { CurrentBlockState, UpcomingEntry, EntryMetaData } from './definitions/runtime/CurrentBlockState.type.js'; // ---> Extra Timer export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './definitions/runtime/AuxTimer.type.js'; diff --git a/packages/utils/index.ts b/packages/utils/index.ts index 8b1fe3883..ae89b44a0 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -74,7 +74,7 @@ export { validateEndAction, validateTimerType } from './src/validate-events/vali // feature business logic -export { calculateTimeUntilStart } from './src/date-utils/calculateTimeUntilStart.js'; +export { getExpectedStart } from './src/date-utils/getExpectedStart.js'; // feature business logic - rundown export { checkIsNow } from './src/date-utils/checkIsNow.js'; diff --git a/packages/utils/src/date-utils/calculateTimeUntilStart.test.ts b/packages/utils/src/date-utils/calculateTimeUntilStart.test.ts deleted file mode 100644 index 9761d7b16..000000000 --- a/packages/utils/src/date-utils/calculateTimeUntilStart.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { OffsetMode } from 'ontime-types'; - -import { calculateTimeUntilStart } from './calculateTimeUntilStart'; -import { dayInMs } from './conversionUtils'; - -describe('calculateTimeUntilStart()', () => { - describe('Absolute offset mode', () => { - test('ontime', () => { - const test = { - timeStart: 100, - dayOffset: 0, - delay: 0, - currentDay: 0, - totalGap: 0, - clock: 90, - offset: 0, - offsetMode: OffsetMode.Absolute, - actualStart: null, - plannedStart: null, - }; - - expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(10); - expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10); - }); - - test('running behind', () => { - const test = { - timeStart: 100, - dayOffset: 0, - delay: 0, - currentDay: 0, - totalGap: 0, - clock: 90, - offset: -20, - offsetMode: OffsetMode.Absolute, - actualStart: null, - plannedStart: null, - }; - - expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(30); - expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(30); - }); - - test('running ahead', () => { - const test = { - timeStart: 100, - dayOffset: 0, - delay: 0, - currentDay: 0, - totalGap: 0, - clock: 80, - offset: 10, - offsetMode: OffsetMode.Absolute, - actualStart: null, - plannedStart: null, - }; - - expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // <-- when running ahead the unlinked timer stays put - expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10); - }); - - test('running behind with enough gaps', () => { - const test = { - timeStart: 100, - dayOffset: 0, - delay: 0, - currentDay: 0, - totalGap: 20, - clock: 50, - offset: -20, - offsetMode: OffsetMode.Absolute, - actualStart: null, - plannedStart: null, - }; - - expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(50); // <-- when gap is enough to compensate for the running behind - expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible - }); - - test('running behind with too little gaps', () => { - const test = { - timeStart: 100, - dayOffset: 0, - delay: 0, - currentDay: 0, - totalGap: 10, - clock: 50, - offset: -20, - offsetMode: OffsetMode.Absolute, - actualStart: 0, - plannedStart: 0, - }; - - expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(60); // <-- when gap is not enough to compensate for the running behind it absorbs at much as possible - expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible - }); - }); - - describe('Relative offset mode', () => { - test('basic function', () => { - const test = { - timeStart: 0, - dayOffset: 0, - delay: 0, - currentDay: 0, - totalGap: 0, - clock: 100, - actualStart: 100, - plannedStart: 0, - offset: 0, - offsetMode: OffsetMode.Relative, - }; - - const timeStartEvent2 = 10; - const timeStartEvent3 = 20; - - //event 1 is the currently running event - - //event 2 - expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(10); - expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(10); - - //event 3 - expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(20); - expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(20); - - // When clock advances by 5ms, time until start should decrease by 5ms - test.clock = 105; - - //event 2 - expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(5); - expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(5); - - //event 3 - expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(15); - expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(15); - }); - - test('gaps', () => { - const test = { - timeStart: 20, - dayOffset: 0, - delay: 0, - currentDay: 0, - totalGap: 10, - clock: 100, - actualStart: 100, - plannedStart: 0, - offset: 0, - offsetMode: OffsetMode.Relative, - }; - - expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20); - expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); - - // When clock advances by 5ms, time until start should decrease by 5ms - test.clock = 105; - expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15); - expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(15); - }); - - test('added/remove time', () => { - const test = { - timeStart: 20, - dayOffset: 0, - delay: 0, - currentDay: 0, - totalGap: 0, - clock: 100, - actualStart: 100, - plannedStart: 0, - offset: 0, - offsetMode: OffsetMode.Relative, - }; - - expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20); - expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); - - test.offset = 5; // remove 5 with addtime - we are ahead of time - - expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15); - expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // unlocked evets will stay on schedule - - test.offset = -5; // add 5 with addtime - we are behind - - expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(25); - expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(25); - }); - - test('next day', () => { - const test = { - delay: 0, - currentDay: 0, - clock: 100, - actualStart: 100, - plannedStart: 0, - offset: 0, - offsetMode: OffsetMode.Relative, - }; - - // this event will start the current day - expect( - calculateTimeUntilStart({ - ...test, - timeStart: 10, - dayOffset: 0, - totalGap: 0, - isLinkedToLoaded: false, - }), - ).toBe(10); - - // this event will start the next day - // in absolute mode this would start in dayInMs - 100 since the gap would compensate - // but in relative mode with and actual start that is 100 offset it starts in dayInMs - expect( - calculateTimeUntilStart({ - ...test, - timeStart: 0, - dayOffset: 1, - totalGap: dayInMs - 20, - isLinkedToLoaded: false, - }), - ).toBe(dayInMs); - - // advancing 100ms - test.clock = 200; - - expect( - calculateTimeUntilStart({ - ...test, - timeStart: 0, - dayOffset: 1, - totalGap: dayInMs - 20, - isLinkedToLoaded: false, - }), - ).toBe(dayInMs - 100); - }); - }); - - test('overlap with negative total gap', () => { - const test = { - dayOffset: 0, - delay: 0, - currentDay: 0, - clock: 100, - actualStart: 100, - plannedStart: 0, - offset: 0, - offsetMode: OffsetMode.Relative, - isLinkedToLoaded: false, - }; - - // the overlap will be pushed out to the expected available time - expect(calculateTimeUntilStart({ ...test, timeStart: 5, totalGap: -5 })).toBe(10); - - test.clock = 105; - }); -}); diff --git a/packages/utils/src/date-utils/getExpectedStart.test.ts b/packages/utils/src/date-utils/getExpectedStart.test.ts new file mode 100644 index 000000000..b3906dcf6 --- /dev/null +++ b/packages/utils/src/date-utils/getExpectedStart.test.ts @@ -0,0 +1,280 @@ +import { OffsetMode } from 'ontime-types'; + +import { dayInMs } from './conversionUtils'; +import { getExpectedStart } from './getExpectedStart'; + +describe('getExpectedStart()', () => { + describe('Absolute offset mode', () => { + test('ontime', () => { + const testEvent = { + timeStart: 100, + delay: 0, + dayOffset: 0, + }; + const testState = { + currentDay: 0, + totalGap: 0, + offset: 0, + offsetMode: OffsetMode.Absolute, + actualStart: null, + plannedStart: null, + }; + + expect(getExpectedStart(testEvent, { ...testState, isLinkedToLoaded: false })).toBe(testEvent.timeStart); + expect(getExpectedStart(testEvent, { ...testState, isLinkedToLoaded: true })).toBe(testEvent.timeStart); + }); + + test('running behind', () => { + const testEvent = { + timeStart: 100, + dayOffset: 0, + delay: 0, + }; + const testState = { + currentDay: 0, + totalGap: 0, + offset: -20, + offsetMode: OffsetMode.Absolute, + actualStart: null, + plannedStart: null, + }; + + expect(getExpectedStart(testEvent, { ...testState, isLinkedToLoaded: false })).toBe(120); + expect(getExpectedStart(testEvent, { ...testState, isLinkedToLoaded: true })).toBe(120); + }); + + test('running ahead', () => { + const testEvent = { + timeStart: 100, + dayOffset: 0, + delay: 0, + }; + const testState = { + currentDay: 0, + totalGap: 0, + offset: 10, + offsetMode: OffsetMode.Absolute, + actualStart: null, + plannedStart: null, + }; + + expect(getExpectedStart(testEvent, { ...testState, isLinkedToLoaded: false })).toBe(100); // <-- when running ahead the unlinked timer stays put + expect(getExpectedStart(testEvent, { ...testState, isLinkedToLoaded: true })).toBe(90); + }); + + test('running behind with enough gaps', () => { + const testEvent = { + timeStart: 100, + dayOffset: 0, + delay: 0, + }; + const testState = { + currentDay: 0, + totalGap: 20, + offset: -20, + offsetMode: OffsetMode.Absolute, + actualStart: null, + plannedStart: null, + }; + + expect(getExpectedStart(testEvent, { ...testState, isLinkedToLoaded: false })).toBe(100); // <-- when gap is enough to compensate for the running behind + // expect(getExpectedStart(testEvent, { ...testState, isLinkedToLoaded: true })).toBe(70); This should not be possible + }); + + test('running behind with too little gaps', () => { + const testEvent = { + timeStart: 100, + dayOffset: 0, + delay: 0, + }; + const testState = { + currentDay: 0, + totalGap: 10, + offset: -20, + offsetMode: OffsetMode.Absolute, + actualStart: 0, + plannedStart: 0, + }; + + expect(getExpectedStart(testEvent, { ...testState, isLinkedToLoaded: false })).toBe(110); // <-- when gap is not enough to compensate for the running behind it absorbs at much as possible + // expect(getExpectedStart(testEvent, { ...testState, isLinkedToLoaded: true })).toBe(70); This should not be possible + }); + }); + + describe('Relative offset mode', () => { + test('basic function', () => { + const testState = { + currentDay: 0, + totalGap: 0, + actualStart: 100, + plannedStart: 0, + offset: 0, + offsetMode: OffsetMode.Relative, + }; + + const timeStartEvent2 = 10; + const timeStartEvent3 = 20; + + //event 1 is the currently running event + + //event 2 + expect( + getExpectedStart( + { dayOffset: 0, delay: 0, timeStart: timeStartEvent2 }, + { ...testState, isLinkedToLoaded: true }, + ), + ).toBe(110); + expect( + getExpectedStart( + { dayOffset: 0, delay: 0, timeStart: timeStartEvent2 }, + { ...testState, isLinkedToLoaded: false }, + ), + ).toBe(110); + + //event 3 + expect( + getExpectedStart( + { dayOffset: 0, delay: 0, timeStart: timeStartEvent3 }, + { ...testState, isLinkedToLoaded: true }, + ), + ).toBe(120); + expect( + getExpectedStart( + { dayOffset: 0, delay: 0, timeStart: timeStartEvent3 }, + { ...testState, isLinkedToLoaded: false }, + ), + ).toBe(120); + + // if we actually started 5ms later + testState.actualStart = 105; + + //event 2 + expect( + getExpectedStart( + { dayOffset: 0, delay: 0, timeStart: timeStartEvent2 }, + { ...testState, isLinkedToLoaded: true }, + ), + ).toBe(115); + expect( + getExpectedStart( + { dayOffset: 0, delay: 0, timeStart: timeStartEvent2 }, + { ...testState, isLinkedToLoaded: false }, + ), + ).toBe(115); + + //event 3 + expect( + getExpectedStart( + { dayOffset: 0, delay: 0, timeStart: timeStartEvent3 }, + { ...testState, isLinkedToLoaded: true }, + ), + ).toBe(125); + expect( + getExpectedStart( + { dayOffset: 0, delay: 0, timeStart: timeStartEvent3 }, + { ...testState, isLinkedToLoaded: false }, + ), + ).toBe(125); + }); + + test('gaps', () => { + const testEvent = { + timeStart: 20, + dayOffset: 0, + delay: 0, + }; + const testState = { + currentDay: 0, + totalGap: 10, + actualStart: 100, + plannedStart: 0, + offset: 0, + offsetMode: OffsetMode.Relative, + }; + + expect(getExpectedStart(testEvent, { ...testState, isLinkedToLoaded: true })).toBe(120); + expect(getExpectedStart(testEvent, { ...testState, isLinkedToLoaded: false })).toBe(120); + + // if we actually started 5ms later + testState.actualStart = 105; + expect(getExpectedStart(testEvent, { ...testState, isLinkedToLoaded: true })).toBe(125); + expect(getExpectedStart(testEvent, { ...testState, isLinkedToLoaded: false })).toBe(125); + }); + + test('added/remove time', () => { + const testEvent = { + timeStart: 20, + dayOffset: 0, + delay: 0, + }; + const testState = { + currentDay: 0, + totalGap: 0, + actualStart: 100, + plannedStart: 0, + offset: 0, + offsetMode: OffsetMode.Relative, + }; + + expect(getExpectedStart(testEvent, { ...testState, isLinkedToLoaded: true })).toBe(120); + expect(getExpectedStart(testEvent, { ...testState, isLinkedToLoaded: false })).toBe(120); + + testState.offset = 5; // remove 5 with addtime - we are ahead of time + + expect(getExpectedStart(testEvent, { ...testState, isLinkedToLoaded: true })).toBe(115); + expect(getExpectedStart(testEvent, { ...testState, isLinkedToLoaded: false })).toBe(120); // unlocked evets will stay on schedule + + testState.offset = -5; // add 5 with addtime - we are behind + + expect(getExpectedStart(testEvent, { ...testState, isLinkedToLoaded: true })).toBe(125); + expect(getExpectedStart(testEvent, { ...testState, isLinkedToLoaded: false })).toBe(125); + }); + + test('next day', () => { + const testState = { + currentDay: 0, + actualStart: 100, + plannedStart: 0, + offset: 0, + offsetMode: OffsetMode.Relative, + }; + + // this event will start the current day + expect( + getExpectedStart( + { timeStart: 10, delay: 0, dayOffset: 0 }, + { ...testState, totalGap: 0, isLinkedToLoaded: false }, + ), + ).toBe(110); + + // this event will start the next day + // in absolute mode this would start in dayInMs - 100 since the gap would compensate + // but in relative mode with and actual start that is 100 offset it starts in dayInMs + expect( + getExpectedStart( + { timeStart: 0, delay: 0, dayOffset: 1 }, + { ...testState, totalGap: dayInMs - 20, isLinkedToLoaded: false }, + ), + ).toBe(dayInMs + 100); + }); + }); + + test('overlap with negative total gap', () => { + const testEvent = { + timeStart: 5, + dayOffset: 0, + delay: 0, + }; + const testState = { + currentDay: 0, + actualStart: 100, + plannedStart: 0, + offset: 0, + offsetMode: OffsetMode.Relative, + isLinkedToLoaded: false, + }; + + // the overlap will be pushed out to the expected available time + expect(getExpectedStart(testEvent, { ...testState, totalGap: -5 })).toBe(110); + }); +}); diff --git a/packages/utils/src/date-utils/calculateTimeUntilStart.ts b/packages/utils/src/date-utils/getExpectedStart.ts similarity index 71% rename from packages/utils/src/date-utils/calculateTimeUntilStart.ts rename to packages/utils/src/date-utils/getExpectedStart.ts index 88194033b..5959443e8 100644 --- a/packages/utils/src/date-utils/calculateTimeUntilStart.ts +++ b/packages/utils/src/date-utils/getExpectedStart.ts @@ -4,6 +4,7 @@ import { OffsetMode } from 'ontime-types'; import { dayInMs } from './conversionUtils.js'; /** + * @param event the event that we are counting to * @param currentDay the day offset of the currently running event * @param totalGap accumulated gap from the current event * @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event @@ -11,31 +12,20 @@ import { dayInMs } from './conversionUtils.js'; * @param offset * @returns */ -export function calculateTimeUntilStart( - data: Pick & { +export function getExpectedStart( + event: Pick, + state: { currentDay: number; totalGap: number; isLinkedToLoaded: boolean; - clock: number; offset: number; offsetMode: OffsetMode; actualStart: MaybeNumber; plannedStart: MaybeNumber; }, ): number { - const { - timeStart, - dayOffset, - currentDay, - totalGap, - isLinkedToLoaded, - clock, - offset, - delay, - offsetMode, - actualStart, - plannedStart, - } = data; + const { timeStart, dayOffset, delay } = event; + const { currentDay, totalGap, isLinkedToLoaded, offset, offsetMode, actualStart, plannedStart } = state; //How many days from the currently running event to this one const relativeDayOffset = dayOffset - currentDay; @@ -51,22 +41,22 @@ export function calculateTimeUntilStart( relativeStartOffset = (actualStart ?? 0) - (plannedStart ?? 0); } - const scheduledTimeUntil = normalisedTimeStart - clock + relativeStartOffset; + const scheduledStartTime = normalisedTimeStart + relativeStartOffset; - const offsetTimeUntil = scheduledTimeUntil - offset; + const offsetStartTime = scheduledStartTime - offset; if (isLinkedToLoaded) { //if we are directly linked back to the loaded event we just follow the offset - return offsetTimeUntil; + return offsetStartTime; } const gapsCanCompensateForOffset = totalGap + offset >= 0; if (gapsCanCompensateForOffset) { // if we are ahead of schedule or the gap can compensate for the amount we are behind then expect to start at the scheduled time - return scheduledTimeUntil; + return scheduledStartTime; } // otherwise consume as much of the offset as possible with the gap - const offsetTimeUntilBufferedByGaps = offsetTimeUntil - totalGap; - return offsetTimeUntilBufferedByGaps; + const offsetStartTimeBufferedByGaps = offsetStartTime - totalGap; + return offsetStartTimeBufferedByGaps; }