diff --git a/apps/client/src/common/utils/time.ts b/apps/client/src/common/utils/time.ts index 9990092ce..76cdb038c 100644 --- a/apps/client/src/common/utils/time.ts +++ b/apps/client/src/common/utils/time.ts @@ -4,6 +4,7 @@ import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, formatFromMillis, + getExpectedEnd, getExpectedStart, } from 'ontime-utils'; @@ -172,23 +173,28 @@ export function getExpectedTimesFromExtendedEvent( ) { if (event === null) return { expectedStart: 0, timeToStart: 0, expectedEnd: 0, plannedEnd: 0 }; + const expectedStartState = { + totalGap: event.totalGap, + isLinkedToLoaded: event.isLinkedToLoaded, + ...state, + }; + const expectedStart = getExpectedStart( { timeStart: event.timeStart, delay: event.delay, dayOffset: event.dayOffset }, - { - totalGap: event.totalGap, - isLinkedToLoaded: event.isLinkedToLoaded, - ...state, - }, + expectedStartState, ); - const plannedEnd = event.timeStart + event.duration + event.delay; + // count to end events are fixed to the scheduled end and ignore delays + const delayToAdd = event.countToEnd ? 0 : event.delay; + const plannedEnd = event.timeStart + event.duration + delayToAdd; + + // we let timeToStart go negative to allow the UI to show due timers + const timeToStart = expectedStart - state.clock; return { expectedStart, - timeToStart: expectedStart - state.clock, - expectedEnd: event.countToEnd - ? Math.max(expectedStart + event.duration, plannedEnd) - : expectedStart + event.duration, + timeToStart, + expectedEnd: getExpectedEnd(event, expectedStartState), plannedEnd, }; } diff --git a/apps/client/src/views/countdown/CountdownSubscriptions.tsx b/apps/client/src/views/countdown/CountdownSubscriptions.tsx index 571af12d3..f7575182d 100644 --- a/apps/client/src/views/countdown/CountdownSubscriptions.tsx +++ b/apps/client/src/views/countdown/CountdownSubscriptions.tsx @@ -151,9 +151,8 @@ type ScheduleTimeProps = { showExpected: boolean; }; //TODO: consider relative mode -export function ScheduleTime(props: ScheduleTimeProps) { - const { event, showExpected } = props; - const { timeStart, duration, delay, expectedStart, countToEnd } = event; +export function ScheduleTime({ event, showExpected }: ScheduleTimeProps) { + const { timeStart, duration, delay, expectedStart, expectedEnd, countToEnd } = event; const plannedStart = timeStart + delay + event.dayOffset * dayInMs; @@ -163,8 +162,14 @@ export function ScheduleTime(props: ScheduleTimeProps) { const plannedStateClass = isExpectedValueShow ? 'sub__schedule--strike' : delay !== 0 ? 'sub__schedule--delayed' : ''; const expectedStateClass = `sub__schedule--${getOffsetState(expectedStart - plannedStart)}`; - const plannedEnd = plannedStart + duration + delay; - const expectedEnd = countToEnd ? Math.max(expectedStart + duration, plannedEnd) : expectedStart + duration; + + // count to end events are fixed to the scheduled end and ignore delays + const plannedEnd = (() => { + if (countToEnd) { + return timeStart + event.dayOffset * dayInMs + duration; + } + return plannedStart + duration; + })(); const expectedEndClass = `sub__schedule--${getOffsetState(expectedEnd - plannedEnd)}`; return ( diff --git a/apps/client/src/views/countdown/SingleEventCountdown.tsx b/apps/client/src/views/countdown/SingleEventCountdown.tsx index 82003e03f..fb5b4f608 100644 --- a/apps/client/src/views/countdown/SingleEventCountdown.tsx +++ b/apps/client/src/views/countdown/SingleEventCountdown.tsx @@ -1,5 +1,4 @@ import { MaybeNumber, OntimeEvent } from 'ontime-types'; -import { getExpectedStart } from 'ontime-utils'; import { IoPencil } from 'react-icons/io5'; import Button from '../../common/components/buttons/Button'; @@ -11,7 +10,7 @@ import { cx } from '../../common/utils/styleUtils'; import SuperscriptTime from '../common/superscript-time/SuperscriptTime'; import { getPropertyValue } from '../common/viewUtils'; import { useCountdownOptions } from './countdown.options'; -import { CountdownTarget, useSubscriptionDisplayData } from './countdown.utils'; +import { CountdownTarget, extendEventData, useSubscriptionDisplayData } from './countdown.utils'; import { ScheduleTime } from './CountdownSubscriptions'; import './SingleEventCountdown.scss'; @@ -27,19 +26,15 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }: const { data: reportData } = useReport(); const { offset, currentDay, actualStart, plannedStart, mode } = useExpectedStartData(); - const { totalGap, isLinkedToLoaded } = subscribedEvent; - const expectedStart = getExpectedStart(subscribedEvent, { + const countdownEvent = extendEventData( + subscribedEvent, currentDay, - totalGap, actualStart, plannedStart, - isLinkedToLoaded, offset, mode, - }); - - const { endedAt } = reportData[subscribedEvent.reportId ?? subscribedEvent.id] ?? { endedAt: null }; - const countdownEvent = { ...subscribedEvent, expectedStart, endedAt }; + reportData, + ); const titleTmp = getPropertyValue(subscribedEvent, mainSource ?? 'title'); const title = titleTmp?.length ? titleTmp : ' '; // insert utf-8 empty space to avoid the line collapsing; // while a group is live, surface the running event's title as the secondary line diff --git a/apps/client/src/views/countdown/countdown.utils.ts b/apps/client/src/views/countdown/countdown.utils.ts index 3a82529f9..f78ac1d53 100644 --- a/apps/client/src/views/countdown/countdown.utils.ts +++ b/apps/client/src/views/countdown/countdown.utils.ts @@ -12,7 +12,7 @@ import { isOntimeGroup, isPlayableEvent, } from 'ontime-types'; -import { MILLIS_PER_MINUTE, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils'; +import { MILLIS_PER_MINUTE, getExpectedEnd, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils'; import { useCountdownSocket } from '../../common/hooks/useSocket'; import { ExtendedEntry } from '../../common/utils/rundownMetadata'; @@ -197,7 +197,11 @@ export type CountdownTarget = ExtendedEntry & { liveEntry?: ExtendedEntry | null; // the running child while a group is live }; -export type CountdownEvent = CountdownTarget & { expectedStart: number; endedAt: MaybeNumber }; +export type CountdownEvent = CountdownTarget & { + expectedStart: number; + expectedEnd: number; + endedAt: MaybeNumber; +}; /** * Resolves a subscription (event or group) into an event-shaped countdown target. @@ -262,7 +266,7 @@ export function extendEventData( reportData: OntimeReport, ): CountdownEvent { const { totalGap, isLinkedToLoaded } = event; - const expectedStart = getExpectedStart(event, { + const expectedStartState = { currentDay, totalGap, actualStart, @@ -270,7 +274,9 @@ export function extendEventData( isLinkedToLoaded, offset, mode, - }); + }; + const expectedStart = getExpectedStart(event, expectedStartState); + const expectedEnd = getExpectedEnd(event, expectedStartState); const { endedAt } = reportData[event.reportId ?? event.id] ?? { endedAt: null }; - return { ...event, expectedStart, endedAt }; + return { ...event, expectedStart, expectedEnd, endedAt }; } diff --git a/apps/server/src/services/__tests__/rollUtils.test.ts b/apps/server/src/services/__tests__/rollUtils.test.ts index 814de4ac2..f4456d76d 100644 --- a/apps/server/src/services/__tests__/rollUtils.test.ts +++ b/apps/server/src/services/__tests__/rollUtils.test.ts @@ -7,11 +7,21 @@ import { initRundown } from '../../api-data/rundown/rundown.service.js'; import { loadRoll } from '../rollUtils.js'; vi.mock('../../classes/data-provider/DataProvider.js', () => { + let automation = { + enabledAutomations: false, + enabledOscIn: false, + oscPortIn: 8888, + triggers: [], + automations: {}, + }; + return { getDataProvider: vi.fn().mockImplementation(() => { return { + getAutomation: vi.fn().mockImplementation(() => automation), setCustomFields: vi.fn().mockImplementation((newData) => newData), setRundown: vi.fn().mockImplementation((newData) => newData), + setAutomation: vi.fn().mockImplementation((newData) => (automation = newData)), }; }), }; diff --git a/apps/server/src/services/__tests__/timerUtils.test.ts b/apps/server/src/services/__tests__/timerUtils.test.ts index 288bbc382..bf011ef76 100644 --- a/apps/server/src/services/__tests__/timerUtils.test.ts +++ b/apps/server/src/services/__tests__/timerUtils.test.ts @@ -225,7 +225,7 @@ describe('getExpectedFinish()', () => { expect(calculatedFinish).toBe(10); }); describe('on timers of type time-to-end', () => { - it('finish time is as schedule + added time', () => { + it('finish time is the fixed end, ignoring added time', () => { const state = { eventNow: { timeEnd: 30, @@ -242,8 +242,31 @@ describe('getExpectedFinish()', () => { }, } as RuntimeState; + // the end is anchored: added time surfaces as offset, it does not move the finish const calculatedFinish = getExpectedFinish(state); - expect(calculatedFinish).toBe(40); + expect(calculatedFinish).toBe(30); + }); + + it('finish time is the fixed end, ignoring pauses', () => { + const state = { + eventNow: { + timeEnd: 30, + countToEnd: true, + }, + clock: 25, + timer: { + addedTime: 0, + duration: dayInMs, + startedAt: 10, + }, + _timer: { + pausedAt: 20, // paused 5 ago - wall clock keeps approaching the fixed end + hasFinished: false, + }, + } as RuntimeState; + + const calculatedFinish = getExpectedFinish(state); + expect(calculatedFinish).toBe(30); }); it('handles events that finish the day after', () => { const state = { @@ -451,7 +474,7 @@ describe('getCurrent()', () => { expect(current).toBe(70); }); - it('current time is the time to end + added time', () => { + it('current time is the time to end, ignoring added time', () => { const state = { eventNow: { timeEnd: 100, @@ -472,8 +495,9 @@ describe('getCurrent()', () => { }, } as RuntimeState; + // counts to the fixed end; added time surfaces as offset, not extra countdown const current = getCurrent(state); - expect(current).toBe(77); + expect(current).toBe(70); }); it('handles events that finish the day after', () => { @@ -1073,7 +1097,7 @@ describe('getRuntimeOffset()', () => { expect(absolute).toBe(0); }); - it('with time-to-end, offset is the overtime', () => { + it('with time-to-end, offset combines overtime and added time', () => { const state = { clock: 82000000, // 22:46:40 eventNow: { @@ -1126,7 +1150,45 @@ describe('getRuntimeOffset()', () => { } as RuntimeState; const { absolute } = getRuntimeOffset(state); - expect(absolute).toBe(400000); // <--- offset is always the overtime + // overtime (400000) plus the operator's added time (-200000) + expect(absolute).toBe(200000); + }); + + it('with time-to-end, added time surfaces as offset', () => { + const state = { + clock: 80000000, // 22:13:20 - before the scheduled end, not in overtime + eventNow: { + id: 'd6a2ce', + timeStart: 77400000, // 21:30:00 + timeEnd: 81000000, // 22:30:00 + duration: 3600000, // 01:00:00 + timeStrategy: TimeStrategy.LockEnd, + countToEnd: true, + dayOffset: 0, + delay: 0, + }, + rundown: { + plannedStart: 77400000, // 21:30:00 + plannedEnd: 81000000, // 22:30:00 + actualStart: 78000000, // 21:40:00 + currentDay: 0, + }, + offset: { + absolute: 0, + }, + timer: { + addedTime: 300000, // operator added 5 minutes + current: 1000000, // still counting down, no overtime + duration: 3600000, + startedAt: 78000000, + }, + _startDayOffset: 0, + _timer: { pausedAt: null }, + } as RuntimeState; + + // the end is anchored, so the added 5 minutes shows up purely as offset + const { absolute } = getRuntimeOffset(state); + expect(absolute).toBe(300000); }); it('handles time-to-end started after the end time', () => { diff --git a/apps/server/src/services/timerUtils.ts b/apps/server/src/services/timerUtils.ts index 79589b4a3..1ec955db8 100644 --- a/apps/server/src/services/timerUtils.ts +++ b/apps/server/src/services/timerUtils.ts @@ -41,7 +41,9 @@ export function getExpectedFinish(state: RuntimeState): MaybeNumber { const pausedTime = pausedAt != null ? clock - pausedAt : 0; if (countToEnd) { - return timeEnd + addedTime + pausedTime; + // count to end events are anchored to their fixed end: added time and pauses + // do not move the end, they surface as offset instead (see getRuntimeOffset) + return timeEnd; } // handle events that finish the day after @@ -74,9 +76,10 @@ export function getCurrent(state: RuntimeState): number { const { clock } = state; if (countToEnd) { + // count to end runs to its fixed end, so added time does not stretch the countdown const isEventOverMidnight = timeStart > timeEnd; const correctDay = isEventOverMidnight ? dayInMs : 0; - return correctDay - clock + timeEnd + addedTime; + return correctDay - clock + timeEnd; } if (startedAt === null) { @@ -180,13 +183,14 @@ export function getRuntimeOffset(state: RuntimeState): { absolute: number; relat const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt; // absolute offset is difference between schedule and playback time - const absolute = eventStartOffset + overtime + pausedTime + addedTime; + // count to end is anchored to its fixed end, so it absorbs the late start (eventStartOffset) + // and the pause (already reflected in overtime); added time surfaces here as offset + const absolute = countToEnd ? overtime + addedTime : eventStartOffset + overtime + pausedTime + addedTime; // the relative offset is the same as the absolute but adjusted relative to the actual start time const relative = absolute + plannedStart - actualStart - _startDayOffset * dayInMs; - // in case of count to end, the absolute offset is just the overtime - return countToEnd ? { absolute: overtime, relative } : { absolute, relative }; + return { absolute, relative }; } /** diff --git a/apps/server/src/stores/__tests__/runtimeState.test.ts b/apps/server/src/stores/__tests__/runtimeState.test.ts index b5b532789..c1e277bbc 100644 --- a/apps/server/src/stores/__tests__/runtimeState.test.ts +++ b/apps/server/src/stores/__tests__/runtimeState.test.ts @@ -56,18 +56,21 @@ const mockState = { } as RuntimeState; vi.mock('../../classes/data-provider/DataProvider.js', () => { + let automation = { + enabledAutomations: false, + enabledOscIn: false, + oscPortIn: 8888, + triggers: [], + automations: {}, + }; + return { getDataProvider: vi.fn().mockImplementation(() => { return { + getAutomation: vi.fn().mockImplementation(() => automation), setCustomFields: vi.fn().mockImplementation((newData) => newData), setRundown: vi.fn().mockImplementation((newData) => newData), - getAutomation: vi.fn().mockReturnValue({ - enabledAutomations: false, - enabledOscIn: false, - oscPortIn: 0, - triggers: [], - automations: {}, - }), + setAutomation: vi.fn().mockImplementation((newData) => (automation = newData)), }; }), }; @@ -325,6 +328,121 @@ describe('mutation on runtimeState', () => { expect(newState.offset.expectedRundownEnd).toBeNull(); }); + test('a countToEnd last event absorbs overtime into its fixed rundown end', async () => { + const entries = { + event1: { + ...mockEvent, + id: 'event1', + timeStart: 10 * MILLIS_PER_HOUR, + timeEnd: 11 * MILLIS_PER_HOUR, + duration: MILLIS_PER_HOUR, + parent: null, + }, + event2: { + ...mockEvent, + id: 'event2', + timeStart: 11 * MILLIS_PER_HOUR, + timeEnd: 12 * MILLIS_PER_HOUR, + duration: MILLIS_PER_HOUR, + countToEnd: true, + linkStart: true, + parent: null, + }, + }; + const mockRundown = makeRundown({ entries, order: ['event1', 'event2'] }); + + await initRundown(mockRundown, {}); + vi.runAllTimers(); + + const { metadata, rundown } = rundownCache.get(); + + // start event1 five minutes behind schedule + vi.setSystemTime('jan 1 10:05'); + load(entries.event1, rundown, metadata); + start(); + update(); + + const newState = getState(); + expect(newState.offset.absolute).toBe(5 * MILLIS_PER_MINUTE); + + // without countToEnd the rundown would end at 12h + 5min, but the countToEnd + // event absorbs the overtime so the rundown is still expected to end at 12h + expect(newState.offset.expectedRundownEnd).toBe(12 * MILLIS_PER_HOUR); + }); + + test('adding time to a running countToEnd event surfaces as offset and end remains fixed', async () => { + const entries = { + event1: { + ...mockEvent, + id: 'event1', + timeStart: 10 * MILLIS_PER_HOUR, + timeEnd: 12 * MILLIS_PER_HOUR, + duration: 2 * MILLIS_PER_HOUR, + countToEnd: true, + parent: null, + }, + }; + const mockRundown = makeRundown({ entries, order: ['event1'] }); + + await initRundown(mockRundown, {}); + vi.runAllTimers(); + + const { metadata, rundown } = rundownCache.get(); + + // start on time + vi.setSystemTime('jan 1 10:00'); + load(entries.event1, rundown, metadata); + start(); + update(); + + let state = getState(); + expect(state.offset.absolute).toBe(0); + expect(state.timer.expectedFinish).toBe(12 * MILLIS_PER_HOUR); + expect(state.offset.expectedRundownEnd).toBe(12 * MILLIS_PER_HOUR); + + // operator adds 5 minutes + addTime(5 * MILLIS_PER_MINUTE); + + state = getState(); + // the end cant move, so the added time is added to the offset + expect(state.offset.absolute).toBe(5 * MILLIS_PER_MINUTE); + expect(state.timer.expectedFinish).toBe(12 * MILLIS_PER_HOUR); + expect(state.offset.expectedRundownEnd).toBe(12 * MILLIS_PER_HOUR); + }); + + test('subtracting more than the remaining time does not finish a countToEnd event', async () => { + const entries = { + event1: { + ...mockEvent, + id: 'event1', + timeStart: 10 * MILLIS_PER_HOUR, + timeEnd: 12 * MILLIS_PER_HOUR, + duration: 2 * MILLIS_PER_HOUR, + countToEnd: true, + parent: null, + }, + }; + const mockRundown = makeRundown({ entries, order: ['event1'] }); + + await initRundown(mockRundown, {}); + vi.runAllTimers(); + + const { metadata, rundown } = rundownCache.get(); + + vi.setSystemTime('jan 1 10:00'); + load(entries.event1, rundown, metadata); + start(); + update(); + + // removing longer than the remaining time cannot finish the event since the end is fixed + addTime(-3 * MILLIS_PER_HOUR); + update(); + + const state = getState(); + expect(state.timer.playback).toBe(Playback.Play); + expect(state.timer.expectedFinish).toBe(12 * MILLIS_PER_HOUR); + }); + test('resume restores currentDay from restore point', async () => { clearState(); const mockRundown = makeRundown({ @@ -1036,4 +1154,32 @@ describe('loadGroupFlagAndEnd()', () => { eventNow: rundown.entries[0], }); }); + + test('a countToEnd event breaks the link chain for the events that follow it', () => { + // chain: A (loaded) -> B (countToEnd, flagged) -> C (linked, last event) + // the chain stays intact up to and including B, but breaks for C since it follows a countToEnd event + const rundown = makeRundown({ + entries: { + A: makeOntimeEvent({ id: 'A', parent: null, linkStart: false, countToEnd: false, gap: 0 }), + B: makeOntimeEvent({ id: 'B', parent: null, linkStart: true, countToEnd: true, gap: 0, flag: true }), + C: makeOntimeEvent({ id: 'C', parent: null, linkStart: true, countToEnd: false, gap: 0 }), + }, + order: ['A', 'B', 'C'], + }); + + const state = { + groupNow: null, + eventNow: rundown.entries.A, + rundown: { actualGroupStart: null }, + } as RuntimeState; + + const metadata = { playableEventOrder: ['A', 'B', 'C'], flags: ['B'] } as RundownMetadata; + + loadGroupFlagAndEnd(rundown, metadata, 0, state); + + // the flag (B) is still part of the chain + expect(state._flag).toMatchObject({ event: rundown.entries.B, isLinkedToLoaded: true }); + // the rundown end (C) follows the countToEnd event, so the chain is broken + expect(state._end).toMatchObject({ event: rundown.entries.C, isLinkedToLoaded: false }); + }); }); diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index d98591285..7b01feefe 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -23,6 +23,7 @@ import { calculateDuration, checkIsNow, dayInMs, + getExpectedEnd, getExpectedStart, getLastEventNormal, isPlaybackActive, @@ -43,6 +44,7 @@ import { hasCrossedMidnight, } from '../services/timerUtils.js'; import { timerConfig } from '../setup/config.js'; +import { shouldCrashDev } from '../utils/development.js'; type ExpectedMetadata = { event: OntimeEvent; @@ -509,23 +511,31 @@ export function addTime(amount: number) { } } - // handle edge cases - // !!! we need to handle side effects before updating the state - const willGoNegative = amount < 0 && Math.abs(amount) > runtimeState.timer.current; - - if (willGoNegative && !runtimeState._timer.hasFinished) { - // set finished time so side effects are triggered - runtimeState._timer.forceFinish = timeCore.timeOfDayNow(); + if (runtimeState.eventNow?.countToEnd) { + // count to end is anchored to its fixed end: added time cannot move the end or finish + // the event early, it only surfaces as offset. `current` is derived from the wall clock + // by the update loop, so we must not bump it here. + runtimeState.timer.addedTime += amount; } else { - const willGoPositive = runtimeState.timer.current < 0 && runtimeState.timer.current + amount > 0; - if (willGoPositive) { - runtimeState._timer.hasFinished = false; + // handle edge cases + // !!! we need to handle side effects before updating the state + const willGoNegative = amount < 0 && Math.abs(amount) > runtimeState.timer.current; + + if (willGoNegative && !runtimeState._timer.hasFinished) { + // set finished time so side effects are triggered + runtimeState._timer.forceFinish = timeCore.timeOfDayNow(); + } else { + const willGoPositive = runtimeState.timer.current < 0 && runtimeState.timer.current + amount > 0; + if (willGoPositive) { + runtimeState._timer.hasFinished = false; + } } + + // we can update the state after handling the side effects + runtimeState.timer.addedTime += amount; + runtimeState.timer.current += amount; } - // we can update the state after handling the side effects - runtimeState.timer.addedTime += amount; - runtimeState.timer.current += amount; runtimeState.timer.elapsed = getElapsed(runtimeState); // update runtime delays: over - under @@ -831,7 +841,6 @@ function getExpectedTimes(state = runtimeState) { state.offset.expectedRundownEnd = null; state.offset.expectedGroupEnd = null; state.offset.expectedFlagStart = null; - state.offset.expectedRundownEnd = null; const { offset } = state; const { plannedStart, actualStart } = state.rundown; @@ -841,9 +850,11 @@ function getExpectedTimes(state = runtimeState) { if (state.groupNow) { const { _group } = state; - if (_group !== null) { - const { event: lastEvent, accumulatedGap, isLinkedToLoaded } = _group; - const lastEventExpectedStart = getExpectedStart(lastEvent, { + DEV: shouldCrashDev(_group === null, 'groupNow is set but _group is null'); + + if (_group) { + const { event, accumulatedGap, isLinkedToLoaded } = _group; + state.offset.expectedGroupEnd = getExpectedEnd(event, { currentDay: state.rundown.currentDay!, totalGap: accumulatedGap, isLinkedToLoaded, @@ -852,15 +863,16 @@ function getExpectedTimes(state = runtimeState) { plannedStart, actualStart, }); - state.offset.expectedGroupEnd = lastEventExpectedStart + lastEvent.duration; } } if (state.eventFlag) { const { _flag } = state; + DEV: shouldCrashDev(_flag === null, 'eventFlag is set but _flag is null'); + if (_flag) { const { event, accumulatedGap, isLinkedToLoaded } = _flag; - const expectedStart = getExpectedStart(event, { + state.offset.expectedFlagStart = getExpectedStart(event, { currentDay: state.rundown.currentDay!, totalGap: accumulatedGap, isLinkedToLoaded, @@ -869,13 +881,13 @@ function getExpectedTimes(state = runtimeState) { plannedStart, actualStart, }); - state.offset.expectedFlagStart = expectedStart; } } if (state._end) { const { event, accumulatedGap, isLinkedToLoaded } = state._end; - const expectedStart = getExpectedStart(event, { + + state.offset.expectedRundownEnd = getExpectedEnd(event, { currentDay: state.rundown.currentDay!, totalGap: accumulatedGap, isLinkedToLoaded, @@ -884,7 +896,6 @@ function getExpectedTimes(state = runtimeState) { plannedStart, actualStart, }); - state.offset.expectedRundownEnd = expectedStart + event.duration; } } @@ -927,6 +938,8 @@ export function loadGroupFlagAndEnd( let accumulatedGap = 0; let isLinkedToLoaded = true; + // a countToEnd event absorbs overtime and breaks the chain + let previousWasCountToEnd = false; for (let idx = currentIndex; idx < playableEventOrder.length; idx++) { const entry = entries[playableEventOrder[idx]]; @@ -935,7 +948,7 @@ export function loadGroupFlagAndEnd( if (idx !== currentIndex) { // we only accumulate data after the loaded event accumulatedGap += entry.gap; - isLinkedToLoaded = isLinkedToLoaded && entry.linkStart; + isLinkedToLoaded = isLinkedToLoaded && entry.linkStart && !previousWasCountToEnd; // and the loaded event is not allowed to be the next flag if (!foundFlag && metadata.flags.includes(entry.id)) { @@ -949,6 +962,8 @@ export function loadGroupFlagAndEnd( foundGroupEnd = true; state._group = { event: lastEventInGroup, isLinkedToLoaded, accumulatedGap }; } + + previousWasCountToEnd = entry.countToEnd; } } diff --git a/packages/utils/index.ts b/packages/utils/index.ts index 6ba939175..eff99e854 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -80,7 +80,7 @@ export { validateEndAction, validateTimerType } from './src/validate-events/vali // feature business logic -export { getExpectedStart } from './src/date-utils/getExpectedStart.js'; +export { getExpectedStart, getExpectedEnd } from './src/date-utils/getExpected.js'; // feature business logic - rundown export { checkIsNow } from './src/date-utils/checkIsNow.js'; diff --git a/packages/utils/src/date-utils/getExpectedStart.test.ts b/packages/utils/src/date-utils/getExpected.test.ts similarity index 70% rename from packages/utils/src/date-utils/getExpectedStart.test.ts rename to packages/utils/src/date-utils/getExpected.test.ts index b24722cdc..c18303c82 100644 --- a/packages/utils/src/date-utils/getExpectedStart.test.ts +++ b/packages/utils/src/date-utils/getExpected.test.ts @@ -1,7 +1,7 @@ import { Day, OffsetMode } from 'ontime-types'; import { MILLIS_PER_HOUR, dayInMs } from './conversionUtils'; -import { getExpectedStart } from './getExpectedStart'; +import { getExpectedEnd, getExpectedStart } from './getExpected'; describe('getExpectedStart()', () => { describe('Absolute offset mode', () => { @@ -315,3 +315,124 @@ describe('getExpectedStart()', () => { expect(getExpectedStart(testEvent, { ...testState, currentDay: 0 })).toBe(23 * MILLIS_PER_HOUR + 5); }); }); + +describe('getExpectedEnd()', () => { + const baseState = { + currentDay: 0, + totalGap: 0, + mode: OffsetMode.Absolute, + actualStart: null, + plannedStart: null, + isLinkedToLoaded: true, + }; + + test('a regular event ends at its expected start plus duration', () => { + const testEvent = { + timeStart: 100, + duration: 50, + delay: 0, + dayOffset: 0 as Day, + countToEnd: false, + }; + + // on schedule + expect(getExpectedEnd(testEvent, { ...baseState, offset: 0 })).toBe(150); + // running 20 behind pushes the end out + expect(getExpectedEnd(testEvent, { ...baseState, offset: 20 })).toBe(170); + }); + + test('a countToEnd event pins to the planned end while in overtime', () => { + const testEvent = { + timeStart: 100, + duration: 50, + delay: 0, + dayOffset: 0 as Day, + countToEnd: true, + }; + + // overtime would otherwise push the end to 170, but countToEnd absorbs it and pins to 150 + expect(getExpectedEnd(testEvent, { ...baseState, offset: 20 })).toBe(150); + }); + + test('an overnight countToEnd event returns a normalised end', () => { + // event starts at 23:00 and counts to 01:00 the next day -> duration spans midnight + const timeStart = 23 * MILLIS_PER_HOUR; + const duration = 2 * MILLIS_PER_HOUR; + const testEvent = { + timeStart, + duration, + delay: 0, + dayOffset: 0 as Day, + countToEnd: true, + }; + + expect(getExpectedEnd(testEvent, { ...baseState, offset: 0 })).toBe(timeStart + duration); + }); + + test('a countToEnd event ignores upstream delays and stays pinned to its fixed end', () => { + const testEvent = { + timeStart: 100, + duration: 50, + delay: 20, + dayOffset: 0 as Day, + }; + + // events shift their schedule based on delay... + expect(getExpectedEnd({ ...testEvent, countToEnd: false }, { ...baseState, offset: 0 })).toBe(170); + // ... but count to end events stay pinned to the scheduled end + expect(getExpectedEnd({ ...testEvent, countToEnd: true }, { ...baseState, offset: 0 })).toBe(150); + }); + + test('a countToEnd event drifts when a delay pushes its start past the fixed end', () => { + const testEvent = { + timeStart: 100, + duration: 50, + delay: 60, + dayOffset: 0 as Day, + countToEnd: true, + }; + + // the delayed start (160) is past the fixed end (150), so the event can no longer + // finish on time and the end follows the compromised start + expect(getExpectedEnd(testEvent, { ...baseState, offset: 0 })).toBe(160); + }); + + test('a countToEnd event on a later day keeps the day offset on the end', () => { + const testEvent = { + timeStart: 100, + duration: 50, + delay: 0, + dayOffset: 1 as Day, + countToEnd: true, + }; + + // the scheduled end must include the day offset (timeStart + dayInMs + duration), + // not collapse to the day-shifted start + expect(getExpectedEnd(testEvent, { ...baseState, currentDay: 0, offset: 0 })).toBe(150 + dayInMs); + // when the running event is already on the same day, no extra day is added + expect(getExpectedEnd({ ...testEvent, dayOffset: 0 as Day }, { ...baseState, currentDay: 0, offset: 0 })).toBe(150); + }); + + test('a countToEnd event is anchored to its wall-clock end in relative mode', () => { + const testEvent = { + timeStart: 100, + duration: 50, + delay: 0, + dayOffset: 0 as Day, + countToEnd: true, + }; + + const relativeState = { + ...baseState, + mode: OffsetMode.Relative, + actualStart: 30, + plannedStart: 0, + offset: 0, + }; + + // a regular event in the same state is shifted by the relative-start offset to 180 + expect(getExpectedEnd({ ...testEvent, countToEnd: false }, relativeState)).toBe(180); + // the countToEnd event stays pinned to its wall-clock end (150), not shifted + expect(getExpectedEnd(testEvent, relativeState)).toBe(150); + }); +}); diff --git a/packages/utils/src/date-utils/getExpectedStart.ts b/packages/utils/src/date-utils/getExpected.ts similarity index 56% rename from packages/utils/src/date-utils/getExpectedStart.ts rename to packages/utils/src/date-utils/getExpected.ts index 716ccae0d..9b179b75c 100644 --- a/packages/utils/src/date-utils/getExpectedStart.ts +++ b/packages/utils/src/date-utils/getExpected.ts @@ -4,25 +4,21 @@ 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 - * @param clock - * @param offset - * @returns + * Runtime context shared by the expected start/end calculations */ +type ExpectedTimesState = { + currentDay: number; // the current day from the rundown + totalGap: number; // accumulated gap from the current event + isLinkedToLoaded: boolean; // is this event part of a chain linking back to the loaded event + offset: number; + mode: OffsetMode; + actualStart: MaybeNumber; + plannedStart: MaybeNumber; +}; + export function getExpectedStart( event: Pick, - state: { - currentDay: number; // the current day from the rundown - totalGap: number; - isLinkedToLoaded: boolean; - offset: number; - mode: OffsetMode; - actualStart: MaybeNumber; - plannedStart: MaybeNumber; - }, + state: ExpectedTimesState, ): number { const { timeStart, dayOffset, delay } = event; const { currentDay, totalGap, isLinkedToLoaded, offset, mode, actualStart, plannedStart } = state; @@ -60,3 +56,27 @@ export function getExpectedStart( const offsetStartTimeBufferedByGaps = offsetStartTime - totalGap; return offsetStartTimeBufferedByGaps; } + +export function getExpectedEnd( + event: Pick, + state: ExpectedTimesState, +): number { + // expected start includes the delay and any offset compensation + const expectedStart = getExpectedStart(event, state); + + /** + * Count to end events are a special case + * - the end time is always the wall clock + */ + if (event.countToEnd) { + // account for day offset + const relativeDayOffset = event.dayOffset - state.currentDay; + const plannedEnd = event.timeStart + event.duration + relativeDayOffset * dayInMs; + + // count to end should finish on the planned time or on start + return Math.max(expectedStart, plannedEnd); + } + + // for normal events, the expected end is when we would start + its duration + return expectedStart + event.duration; +}