diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts index a0da4b3ed..10a6f9c4e 100644 --- a/apps/client/src/common/hooks/useSocket.ts +++ b/apps/client/src/common/hooks/useSocket.ts @@ -174,6 +174,7 @@ export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) = offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset, blockStartedAt: state.blockNow?.startedAt ?? null, + blockExpectedEnd: state.blockNow?.expectedEnd ?? null, })); export const useTimelineStatus = createSelector((state: RuntimeStore) => ({ diff --git a/apps/client/src/common/utils/__tests__/time.test.ts b/apps/client/src/common/utils/__tests__/time.test.ts index a518ac0e5..b79ce13b9 100644 --- a/apps/client/src/common/utils/__tests__/time.test.ts +++ b/apps/client/src/common/utils/__tests__/time.test.ts @@ -1,7 +1,4 @@ -import { OffsetMode } from 'ontime-types'; -import { dayInMs } from 'ontime-utils'; - -import { calculateTimeUntilStart, formatTime, nowInMillis } from '../time'; +import { formatTime, nowInMillis } from '../time'; describe('nowInMillis()', () => { it('should return the current time in milliseconds', () => { @@ -41,257 +38,3 @@ describe('formatTime()', () => { expect(time).toStrictEqual('-01:00'); }); }); - -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/apps/client/src/common/utils/time.ts b/apps/client/src/common/utils/time.ts index 760be0f54..d85ddc176 100644 --- a/apps/client/src/common/utils/time.ts +++ b/apps/client/src/common/utils/time.ts @@ -1,5 +1,11 @@ -import { MaybeNumber, OffsetMode, OntimeEvent, Settings, TimeFormat } from 'ontime-types'; -import { dayInMs, formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils'; +import { MaybeNumber, OntimeEvent, Settings, TimeFormat } from 'ontime-types'; +import { + calculateTimeUntilStart, + formatFromMillis, + MILLIS_PER_HOUR, + MILLIS_PER_MINUTE, + MILLIS_PER_SECOND, +} from 'ontime-utils'; import { FORMAT_12, FORMAT_24 } from '../../viewerConfig'; import { APP_SETTINGS } from '../api/constants'; @@ -143,72 +149,3 @@ export function useTimeUntilStart( const { offset, clock, currentDay, offsetMode, actualStart, plannedStart } = useTimeUntilData(); return calculateTimeUntilStart({ ...data, currentDay, clock, offset, offsetMode, actualStart, plannedStart }); } - -/** - * - * @param currentDay the day offset of the urrently 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 - * @param clock - * @param offset - * @returns - */ -export function calculateTimeUntilStart( - data: Pick & { - 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; - - //How many days from the currently running event to this one - const relativeDayOffset = dayOffset - currentDay; - - const delayedStart = Math.max(0, timeStart + delay); - - //The normalised start time of this event relative to the currently running event - const normalisedTimeStart = delayedStart + relativeDayOffset * dayInMs; - - let relativeStartOffset = 0; - - if (offsetMode === OffsetMode.Relative) { - relativeStartOffset = (actualStart ?? 0) - (plannedStart ?? 0); - } - - const scheduledTimeUntil = normalisedTimeStart - clock + relativeStartOffset; - - const offsetTimeUntil = scheduledTimeUntil - offset; - - if (isLinkedToLoaded) { - //if we are directly linked back to the loaded event we just follow the offset - return offsetTimeUntil; - } - - 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; - } - - // otherwise consume as much of the offset as possible with the gap - const offsetTimeUntilBufferedByGaps = offsetTimeUntil - totalGap; - return offsetTimeUntilBufferedByGaps; -} diff --git a/apps/client/src/features/overview/composite/OverviewWrapper.tsx b/apps/client/src/features/overview/composite/OverviewWrapper.tsx index fb7cd9177..11e242413 100644 --- a/apps/client/src/features/overview/composite/OverviewWrapper.tsx +++ b/apps/client/src/features/overview/composite/OverviewWrapper.tsx @@ -1,8 +1,9 @@ import { PropsWithChildren, ReactNode } from 'react'; import { ErrorBoundary } from '@sentry/react'; -import { isOntimeBlock } from 'ontime-types'; +import { isOntimeBlock, TimerType } from 'ontime-types'; import { isPlaybackActive, millisToString } from 'ontime-utils'; +import Tooltip from '../../../common/components/tooltip/Tooltip'; import { useClock, useCurrentBlockId, @@ -12,7 +13,7 @@ import { } from '../../../common/hooks/useSocket'; import useProjectData from '../../../common/hooks-query/useProjectData'; import { useEntry } from '../../../common/hooks-query/useRundown'; -import { cx, enDash, timerPlaceholder, timerPlaceholderMin } from '../../../common/utils/styleUtils'; +import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils'; import { formatedTime, getOffsetText } from '../overviewUtils'; import { TimeColumn, TimeRow } from './TimeLayout'; @@ -51,46 +52,61 @@ export function TitlesOverview() { } export function CurrentBlockOverview() { - const { blockStartedAt: blockStartAt, clock } = useRuntimePlaybackOverview(); + const { blockStartedAt, clock, blockExpectedEnd } = useRuntimePlaybackOverview(); const { currentBlockId } = useCurrentBlockId(); const entry = useEntry(currentBlockId); - const timeInBlock = formatedTime(blockStartAt ? clock - blockStartAt : null, 2); + const timeInBlock = formatedTime(blockStartedAt ? clock - blockStartedAt : null, 3, TimerType.CountUp); + const blockExpectedEndString = formatedTime(blockExpectedEnd, 3, TimerType.CountUp); - /** - * The time to the end of the block - * as scheduled - * TODO(v4): this needs to be calculated according to offset mode - */ - const blockEnd = (() => { - if (!entry) return timerPlaceholderMin; - if (!isOntimeBlock(entry)) return timerPlaceholderMin; - if (entry.timeEnd === null) return timerPlaceholderMin; - return formatedTime(entry.timeEnd - clock, 2); + const remainingBlockDuration = (() => { + if (blockStartedAt === null || !entry) return timerPlaceholder; + if (!isOntimeBlock(entry)) return timerPlaceholder; + return formatedTime(blockStartedAt + entry.duration - clock, 3, TimerType.CountDown); })(); - /** - * The time to the end of the block - * as projected accounting for delays and offset - * TODO(v4): this needs to be calculated according to offset mode - */ - const projectedBlockEnd = (() => { - if (blockStartAt === null || !entry) return timerPlaceholderMin; - if (!isOntimeBlock(entry)) return timerPlaceholderMin; - return formatedTime(blockStartAt + entry.duration - clock, 2); - })(); + const timeUntilBlockEnd = (() => { + if (blockExpectedEnd === null) return timerPlaceholder; + return formatedTime(blockExpectedEnd - clock, 3, TimerType.CountDown); + })() ; return ( <> -
- - + + + + + + +
+
+ + + + + +
); diff --git a/apps/client/src/features/overview/composite/TimeLayout.module.scss b/apps/client/src/features/overview/composite/TimeLayout.module.scss index baeb795a7..e79581ec8 100644 --- a/apps/client/src/features/overview/composite/TimeLayout.module.scss +++ b/apps/client/src/features/overview/composite/TimeLayout.module.scss @@ -1,7 +1,7 @@ .label { color: $label-gray; font-size: calc(1rem - 2px); - width: 10em; // a number large enough to force right alignment + width: 15em; // a number large enough to force right alignment } .clock { diff --git a/apps/client/src/features/overview/overviewUtils.ts b/apps/client/src/features/overview/overviewUtils.ts index 9ac7da342..e0a2ce90b 100644 --- a/apps/client/src/features/overview/overviewUtils.ts +++ b/apps/client/src/features/overview/overviewUtils.ts @@ -1,4 +1,4 @@ -import { MaybeNumber } from 'ontime-types'; +import { MaybeNumber, TimerType } from 'ontime-types'; import { dayInMs, millisToString } from 'ontime-utils'; import { enDash, timerPlaceholder, timerPlaceholderMin } from '../../common/utils/styleUtils'; @@ -6,8 +6,12 @@ import { enDash, timerPlaceholder, timerPlaceholderMin } from '../../common/util /** * Encapsulates the logic for formatting time in overview */ -export function formatedTime(time: MaybeNumber, segments: number = 3): string { - return millisToString(time, { fallback: segments === 3 ? timerPlaceholder : timerPlaceholderMin }); +export function formatedTime( + time: MaybeNumber, + segments: number = 3, + direction?: TimerType.CountDown | TimerType.CountUp, +): string { + return millisToString(time, { fallback: segments === 3 ? timerPlaceholder : timerPlaceholderMin, direction }); } /** diff --git a/apps/server/src/services/runtime-service/RuntimeService.ts b/apps/server/src/services/runtime-service/RuntimeService.ts index b66aab324..ae0118de2 100644 --- a/apps/server/src/services/runtime-service/RuntimeService.ts +++ b/apps/server/src/services/runtime-service/RuntimeService.ts @@ -771,11 +771,12 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert !deepEqual(RuntimeService.previousState?.runtime, state.runtime); /** - * the currentBlock object has no ticking values so we only need to check for equality + * the currentBlock object has the potential to tick on expected end + * TODO: the value shows up one tick to late */ const shouldBlockUpdate = !deepEqual(RuntimeService?.previousState.blockNow, state.blockNow) || - !deepEqual(RuntimeService?.previousState.blockNext, state.blockNext); + RuntimeService?.previousState.blockNext !== state.blockNext; /** * Many other values are calculated based on the clock @@ -806,7 +807,7 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert batch.add('blockNow', state.blockNow); batch.add('blockNext', state.blockNext); RuntimeService.previousState.blockNow = structuredClone(state.blockNow); - RuntimeService.previousState.blockNext = structuredClone(state.blockNext); + RuntimeService.previousState.blockNext = state.blockNext; } if (hasImmediateChanges) { diff --git a/apps/server/src/services/timerUtils.ts b/apps/server/src/services/timerUtils.ts index 3cca39718..4cf4f1bc7 100644 --- a/apps/server/src/services/timerUtils.ts +++ b/apps/server/src/services/timerUtils.ts @@ -1,12 +1,65 @@ -import { MaybeNumber, TimerPhase } from 'ontime-types'; -import { dayInMs, isPlaybackActive } from 'ontime-utils'; +import { isOntimeEvent, MaybeNumber, OntimeBlock, OntimeEvent, Rundown, TimerPhase } from 'ontime-types'; +import { calculateTimeUntilStart, 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 lastEntry = entries[orderInBlock.at(-1)!] as OntimeEvent; + const { offsetMode, offset, plannedStart, actualStart } = state.runtime; + + const timeUntilLastEvent = calculateTimeUntilStart({ + timeStart: lastEntry.timeStart, + dayOffset: lastEntry.dayOffset, + delay: lastEntry.delay, + currentDay: eventNow.dayOffset, + totalGap, + isLinkedToLoaded, + clock, + offsetMode, + offset, + plannedStart, + actualStart, + }); + + return clock + timeUntilLastEvent + lastEntry.duration; +} + /** * Calculates expected finish time of a running timer * @param {RuntimeState} state runtime state diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index 88b992492..2bc5d35ae 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -18,6 +18,7 @@ import { timeNow } from '../utils/time.js'; import type { RestorePoint } from '../services/RestoreService.js'; import { getCurrent, + getExpectedBlockFinish, getExpectedEnd, getExpectedFinish, getRuntimeOffset, @@ -27,11 +28,12 @@ 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'; export type RuntimeState = { clock: number; // realtime clock blockNow: BlockState | null; - blockNext: BlockState | null; + blockNext: MaybeString; eventNow: PlayableEvent | null; eventNext: PlayableEvent | null; runtime: Runtime; @@ -90,6 +92,8 @@ export function clearEventData() { runtimeState.runtime.expectedEnd = null; runtimeState.runtime.selectedEventIndex = null; + if (runtimeState.blockNow) runtimeState.blockNow.expectedEnd = null; + runtimeState.timer.playback = Playback.Stop; runtimeState.clock = timeNow(); runtimeState.timer = { ...runtimeStorePlaceholder.timer }; @@ -509,6 +513,11 @@ export function update(): UpdateResult { runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState); } + if (runtimeState.blockNow) { + const expectedBlockFinish = getExpectedBlockFinish(runtimeState, getCurrentRundown()); + runtimeState.blockNow.expectedEnd = expectedBlockFinish; + } + return { hasTimerFinished: finishedNow, hasSecondaryTimerFinished: false }; function updateIfIdle() { @@ -690,7 +699,7 @@ export function loadBlock(rundown: Rundown, state = runtimeState) { let foundEventNow = false; for (const id of rundown.order) { if (foundEventNow && isOntimeBlock(rundown.entries[id])) { - state.blockNext = { id, startedAt: null }; // the id is set here, the start time is set in other placed that handel starting events + 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) { @@ -707,7 +716,7 @@ export function loadBlock(rundown: Rundown, state = runtimeState) { //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 }; // the id is set here, the start time is set in other placed that handel starting events + state.blockNow = { id: currentBlockId, startedAt: null, expectedEnd: null }; // the id is set here, the start time is set in other placed that handel starting events } } diff --git a/e2e/tests/features/204-editor-crud.spec.ts b/e2e/tests/features/204-editor-crud.spec.ts index 06e64277d..12f20e70d 100644 --- a/e2e/tests/features/204-editor-crud.spec.ts +++ b/e2e/tests/features/204-editor-crud.spec.ts @@ -17,7 +17,7 @@ test('CRUD operations on the rundown', async ({ page }) => { await expect(page.getByTestId('rundown-block')).toHaveCount(0); // create groups using the quick add buttons - await page.getByRole('button', { name: 'Group' }).nth(1).click(); + await page.getByTestId('rundown').getByRole('button', { name: 'Group' }).nth(1).click(); await page.getByRole('button', { name: 'Delay' }).nth(1).click(); await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click(); await expect(page.getByTestId('rundown-event')).toHaveCount(2); diff --git a/packages/types/src/definitions/runtime/CurrentBlockState.type.ts b/packages/types/src/definitions/runtime/CurrentBlockState.type.ts index 009157b82..5ff9900dc 100644 --- a/packages/types/src/definitions/runtime/CurrentBlockState.type.ts +++ b/packages/types/src/definitions/runtime/CurrentBlockState.type.ts @@ -4,4 +4,5 @@ import type { EntryId } from '../core/OntimeEntry.js'; export type BlockState = { id: EntryId; startedAt: MaybeNumber; + expectedEnd: MaybeNumber; }; diff --git a/packages/types/src/definitions/runtime/RuntimeStore.type.ts b/packages/types/src/definitions/runtime/RuntimeStore.type.ts index 099d3f0d5..fc38c789d 100644 --- a/packages/types/src/definitions/runtime/RuntimeStore.type.ts +++ b/packages/types/src/definitions/runtime/RuntimeStore.type.ts @@ -1,3 +1,4 @@ +import type { MaybeString } from '../../utils/utils.type.js'; import type { OntimeEvent } from '../core/OntimeEntry.js'; import type { SimpleTimerState } from './AuxTimer.type.js'; import type { BlockState } from './CurrentBlockState.type.js'; @@ -18,9 +19,9 @@ export type RuntimeStore = { runtime: Runtime; eventNow: OntimeEvent | null; eventNext: OntimeEvent | null; - + blockNow: BlockState | null; - blockNext: BlockState | null; + blockNext: MaybeString; // extra timers auxtimer1: SimpleTimerState; diff --git a/packages/utils/index.ts b/packages/utils/index.ts index ef111f0c7..274999a5e 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -74,6 +74,8 @@ export { validateEndAction, validateTimerType } from './src/validate-events/vali // feature business logic +export { calculateTimeUntilStart } from './src/date-utils/calculateTimeUntilStart.js'; + // feature business logic - rundown export { checkIsNow } from './src/date-utils/checkIsNow.js'; export { checkIsNextDay } from './src/date-utils/checkIsNextDay.js'; diff --git a/packages/utils/src/date-utils/calculateTimeUntilStart.test.ts b/packages/utils/src/date-utils/calculateTimeUntilStart.test.ts new file mode 100644 index 000000000..9761d7b16 --- /dev/null +++ b/packages/utils/src/date-utils/calculateTimeUntilStart.test.ts @@ -0,0 +1,258 @@ +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/calculateTimeUntilStart.ts b/packages/utils/src/date-utils/calculateTimeUntilStart.ts new file mode 100644 index 000000000..88194033b --- /dev/null +++ b/packages/utils/src/date-utils/calculateTimeUntilStart.ts @@ -0,0 +1,72 @@ +import type { MaybeNumber, OntimeEvent } from 'ontime-types'; +import { OffsetMode } from 'ontime-types'; + +import { dayInMs } from './conversionUtils.js'; + +/** + * @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 + * @param clock + * @param offset + * @returns + */ +export function calculateTimeUntilStart( + data: Pick & { + 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; + + //How many days from the currently running event to this one + const relativeDayOffset = dayOffset - currentDay; + + const delayedStart = Math.max(0, timeStart + delay); + + //The normalised start time of this event relative to the currently running event + const normalisedTimeStart = delayedStart + relativeDayOffset * dayInMs; + + let relativeStartOffset = 0; + + if (offsetMode === OffsetMode.Relative) { + relativeStartOffset = (actualStart ?? 0) - (plannedStart ?? 0); + } + + const scheduledTimeUntil = normalisedTimeStart - clock + relativeStartOffset; + + const offsetTimeUntil = scheduledTimeUntil - offset; + + if (isLinkedToLoaded) { + //if we are directly linked back to the loaded event we just follow the offset + return offsetTimeUntil; + } + + 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; + } + + // otherwise consume as much of the offset as possible with the gap + const offsetTimeUntilBufferedByGaps = offsetTimeUntil - totalGap; + return offsetTimeUntilBufferedByGaps; +}