diff --git a/apps/client/src/common/utils/time.ts b/apps/client/src/common/utils/time.ts index 8e01b4496..bdca9c8f7 100644 --- a/apps/client/src/common/utils/time.ts +++ b/apps/client/src/common/utils/time.ts @@ -5,6 +5,7 @@ import { MILLIS_PER_SECOND, dayInMs, formatFromMillis, + getExpectedEnd, getExpectedStart, } from 'ontime-utils'; @@ -186,15 +187,13 @@ export function getExpectedTimesFromExtendedEvent( ...state, }, ); - + const expectedEnd = getExpectedEnd(event, expectedStart, state.currentDay); const plannedEnd = event.timeStart + event.duration + event.delay; return { expectedStart, timeToStart: expectedStart - state.clock, - expectedEnd: event.countToEnd - ? Math.max(expectedStart + event.duration, plannedEnd) - : expectedStart + event.duration, + expectedEnd, plannedEnd, }; } diff --git a/apps/client/src/views/countdown/CountdownSubscriptions.tsx b/apps/client/src/views/countdown/CountdownSubscriptions.tsx index 571af12d3..ced544457 100644 --- a/apps/client/src/views/countdown/CountdownSubscriptions.tsx +++ b/apps/client/src/views/countdown/CountdownSubscriptions.tsx @@ -153,7 +153,7 @@ type ScheduleTimeProps = { //TODO: consider relative mode export function ScheduleTime(props: ScheduleTimeProps) { const { event, showExpected } = props; - const { timeStart, duration, delay, expectedStart, countToEnd } = event; + const { timeStart, duration, delay, expectedStart, expectedEnd } = event; const plannedStart = timeStart + delay + event.dayOffset * dayInMs; @@ -164,7 +164,6 @@ export function ScheduleTime(props: ScheduleTimeProps) { const expectedStateClass = `sub__schedule--${getOffsetState(expectedStart - plannedStart)}`; const plannedEnd = plannedStart + duration + delay; - const expectedEnd = countToEnd ? Math.max(expectedStart + duration, plannedEnd) : expectedStart + 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..b755f1998 100644 --- a/apps/client/src/views/countdown/SingleEventCountdown.tsx +++ b/apps/client/src/views/countdown/SingleEventCountdown.tsx @@ -1,17 +1,15 @@ -import { MaybeNumber, OntimeEvent } from 'ontime-types'; -import { getExpectedStart } from 'ontime-utils'; +import { getExpectedEnd, getExpectedStart } from 'ontime-utils'; import { IoPencil } from 'react-icons/io5'; import Button from '../../common/components/buttons/Button'; import useReport from '../../common/hooks-query/useReport'; import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity'; import { useExpectedStartData } from '../../common/hooks/useSocket'; -import { ExtendedEntry } from '../../common/utils/rundownMetadata'; 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 { CountdownEvent, CountdownTarget, useSubscriptionDisplayData } from './countdown.utils'; import { ScheduleTime } from './CountdownSubscriptions'; import './SingleEventCountdown.scss'; @@ -38,8 +36,10 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }: mode, }); + const expectedEnd = getExpectedEnd(subscribedEvent, expectedStart, currentDay); + const { endedAt } = reportData[subscribedEvent.reportId ?? subscribedEvent.id] ?? { endedAt: null }; - const countdownEvent = { ...subscribedEvent, expectedStart, endedAt }; + const countdownEvent = { ...subscribedEvent, expectedStart, endedAt, expectedEnd }; 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 @@ -64,7 +64,7 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }: } interface SubscriptionStatusProps { - event: ExtendedEntry & { endedAt: MaybeNumber; expectedStart: number }; + event: CountdownEvent; } function SubscriptionStatus({ event }: SubscriptionStatusProps) { diff --git a/apps/client/src/views/countdown/countdown.utils.ts b/apps/client/src/views/countdown/countdown.utils.ts index 3a82529f9..746b38bbe 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,7 @@ 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; endedAt: MaybeNumber; expectedEnd: number }; /** * Resolves a subscription (event or group) into an event-shaped countdown target. @@ -271,6 +271,7 @@ export function extendEventData( offset, mode, }); + const expectedEnd = getExpectedEnd(event, expectedStart, currentDay); const { endedAt } = reportData[event.reportId ?? event.id] ?? { endedAt: null }; - return { ...event, expectedStart, endedAt }; + return { ...event, expectedStart, endedAt, expectedEnd }; } diff --git a/apps/server/src/services/__tests__/timerUtils.test.ts b/apps/server/src/services/__tests__/timerUtils.test.ts index 288bbc382..eecc5893a 100644 --- a/apps/server/src/services/__tests__/timerUtils.test.ts +++ b/apps/server/src/services/__tests__/timerUtils.test.ts @@ -1126,7 +1126,7 @@ describe('getRuntimeOffset()', () => { } as RuntimeState; const { absolute } = getRuntimeOffset(state); - expect(absolute).toBe(400000); // <--- offset is always the overtime + expect(absolute).toBe(400000 - 200000); // <--- offset is always the overtime + added time }); 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..8c28a5e8d 100644 --- a/apps/server/src/services/timerUtils.ts +++ b/apps/server/src/services/timerUtils.ts @@ -180,13 +180,13 @@ 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; + // in case of count to end, the absolute offset is overtime and added time + 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..3031f32db 100644 --- a/apps/server/src/stores/__tests__/runtimeState.test.ts +++ b/apps/server/src/stores/__tests__/runtimeState.test.ts @@ -1036,4 +1036,167 @@ describe('loadGroupFlagAndEnd()', () => { eventNow: rundown.entries[0], }); }); + + test('countToEnd entries decouple the link chain for subsequent events', () => { + // Event 0 (loaded): no countToEnd, linkStart=true + // Event 1: countToEnd=true → breaks the link chain + // Event 2: linkStart=true → unlinked (chain broken by event 1) + // Event 3: flag event → also unlinked + const rundown = makeRundown({ + entries: { + group: makeOntimeGroup({ id: 'group', entries: ['0', '1', '2'] }), + 0: makeOntimeEvent({ + id: '0', + parent: 'group', + timeStart: 0, + duration: 3600000, + countToEnd: false, + linkStart: true, + gap: 0, + } as any), + 1: makeOntimeEvent({ + id: '1', + parent: 'group', + timeStart: 3600000, + duration: 3600000, + countToEnd: true, + linkStart: true, + gap: 0, + } as any), + 2: makeOntimeEvent({ + id: '2', + parent: 'group', + timeStart: 7200000, + duration: 3600000, + linkStart: true, + gap: 0, + } as any), + 3: makeOntimeEvent({ + id: '3', + parent: null, + timeStart: 10800000, + duration: 3600000, + linkStart: true, + gap: 0, + } as any), + }, + order: ['group', '0', '1', '2', '3'], + }); + + const state = { + groupNow: null, + eventNow: rundown.entries[0], + rundown: { actualGroupStart: null }, + } as RuntimeState; + + const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata; + + loadGroupFlagAndEnd(rundown, metadata, 0, state); + + // _group is the last event in the group (event 2) + // isLinkedToLoaded is false because event 1 (between loaded and group end) has countToEnd=true + // accumulatedGap includes event 1's duration carried forward to event 2 + // (the countToEnd duration is applied to the next event, not the countToEnd event itself) + expect(state._group).toMatchObject({ + event: rundown.entries[2], + isLinkedToLoaded: false, + accumulatedGap: 3600000, + }); + + // _flag (event 3): isLinkedToLoaded is false because chain was broken by event 1 + // accumulatedGap is still 3600000 because event 2 is not countToEnd, + // so previousWasCountToEnd is null and no further duration is carried forward + expect(state._flag).toMatchObject({ + event: rundown.entries[3], + isLinkedToLoaded: false, + accumulatedGap: 3600000, + }); + + // _end (event 3): also unlinked because the chain was broken at event 1 + expect(state._end).toMatchObject({ + event: rundown.entries[3], + isLinkedToLoaded: false, + }); + }); + + test('countToEnd in the middle of the chain breaks links for downstream events', () => { + // Event 0 (loaded): no countToEnd, linkStart=true + // Event 1: linkStart=true → linked to loaded + // Event 2: countToEnd=true → breaks the chain (also last in group) + // Event 3: linkStart=true → unlinked (chain broken by event 2) + const rundown = makeRundown({ + entries: { + group: makeOntimeGroup({ id: 'group', entries: ['0', '1', '2'] }), + 0: makeOntimeEvent({ + id: '0', + parent: 'group', + timeStart: 0, + duration: 3600000, + countToEnd: false, + linkStart: true, + gap: 0, + } as any), + 1: makeOntimeEvent({ + id: '1', + parent: 'group', + timeStart: 3600000, + duration: 3600000, + linkStart: true, + gap: 0, + } as any), + 2: makeOntimeEvent({ + id: '2', + parent: 'group', + timeStart: 7200000, + duration: 3600000, + countToEnd: true, + linkStart: true, + gap: 0, + } as any), + 3: makeOntimeEvent({ + id: '3', + parent: null, + timeStart: 10800000, + duration: 3600000, + linkStart: true, + gap: 0, + } as any), + }, + order: ['group', '0', '1', '2', '3'], + }); + + const state = { + groupNow: null, + eventNow: rundown.entries[0], + rundown: { actualGroupStart: null }, + } as RuntimeState; + + const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata; + + loadGroupFlagAndEnd(rundown, metadata, 0, state); + + // _group is the last event in the group (event 2) + // isLinkedToLoaded is true because no preceding event had countToEnd + // accumulatedGap is 0 because no preceding event was countToEnd + // (the countToEnd duration is carried forward to the next event, not added to the countToEnd event itself) + expect(state._group).toMatchObject({ + event: rundown.entries[2], + isLinkedToLoaded: true, + accumulatedGap: 0, + }); + + // _flag (event 3): isLinkedToLoaded is false because chain was broken by count-to-end event 2 + // accumulatedGap includes event 2's duration carried forward + expect(state._flag).toMatchObject({ + event: rundown.entries[3], + isLinkedToLoaded: false, + accumulatedGap: 3600000, + }); + + // _end (event 3): unlinked because event 2 has countToEnd=true + expect(state._end).toMatchObject({ + event: rundown.entries[3], + isLinkedToLoaded: false, + }); + }); }); diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index d98591285..f306b154e 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, @@ -831,7 +832,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; @@ -852,7 +852,7 @@ function getExpectedTimes(state = runtimeState) { plannedStart, actualStart, }); - state.offset.expectedGroupEnd = lastEventExpectedStart + lastEvent.duration; + state.offset.expectedGroupEnd = getExpectedEnd(lastEvent, lastEventExpectedStart, state.rundown.currentDay!); } } @@ -884,7 +884,7 @@ function getExpectedTimes(state = runtimeState) { plannedStart, actualStart, }); - state.offset.expectedRundownEnd = expectedStart + event.duration; + state.offset.expectedRundownEnd = getExpectedEnd(event, expectedStart, state.rundown.currentDay!); } } @@ -927,6 +927,7 @@ export function loadGroupFlagAndEnd( let accumulatedGap = 0; let isLinkedToLoaded = true; + let previousWasCountToEnd: Maybe = null; for (let idx = currentIndex; idx < playableEventOrder.length; idx++) { const entry = entries[playableEventOrder[idx]]; @@ -934,8 +935,23 @@ export function loadGroupFlagAndEnd( if (isOntimeEvent(entry)) { if (idx !== currentIndex) { // we only accumulate data after the loaded event - accumulatedGap += entry.gap; - isLinkedToLoaded = isLinkedToLoaded && entry.linkStart; + + if (previousWasCountToEnd !== null) { + /** previous event was countToEnd: add its duration as a positive gap (it "gives back" time downstream) + * and break the link to the loaded event since countToEnd events reset the schedule + */ + accumulatedGap += entry.gap + previousWasCountToEnd; + isLinkedToLoaded = false; + } else { + accumulatedGap += entry.gap; + isLinkedToLoaded = isLinkedToLoaded && entry.linkStart; + } + + if (entry.countToEnd) { + previousWasCountToEnd = entry.duration; + } else { + previousWasCountToEnd = null; + } // and the loaded event is not allowed to be the next flag if (!foundFlag && metadata.flags.includes(entry.id)) { 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 64% rename from packages/utils/src/date-utils/getExpectedStart.test.ts rename to packages/utils/src/date-utils/getExpected.test.ts index b24722cdc..b5f272a3f 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,144 @@ 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 + const expectedStart0 = getExpectedStart(testEvent, { ...baseState, offset: 0 }); + expect(getExpectedEnd(testEvent, expectedStart0, baseState.currentDay)).toBe(150); + + // running 20 behind pushes the end out + const expectedStart20 = getExpectedStart(testEvent, { ...baseState, offset: 20 }); + expect(getExpectedEnd(testEvent, expectedStart20, baseState.currentDay)).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 + const expectedStart = getExpectedStart(testEvent, { ...baseState, offset: 20 }); + expect(getExpectedEnd(testEvent, expectedStart, baseState.currentDay)).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, + }; + + const expectedStart = getExpectedStart(testEvent, { ...baseState, offset: 0 }); + expect(getExpectedEnd(testEvent, expectedStart, baseState.currentDay)).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... + const expectedStart = getExpectedStart({ ...testEvent }, { ...baseState, offset: 0 }); + expect(getExpectedEnd({ ...testEvent, countToEnd: false }, expectedStart, baseState.currentDay)).toBe(170); + + // ... but count to end events stay pinned to the scheduled end + const expectedStartCountToEnd = getExpectedStart({ ...testEvent }, { ...baseState, offset: 0 }); + expect(getExpectedEnd({ ...testEvent, countToEnd: true }, expectedStartCountToEnd, baseState.currentDay)).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 + const expectedStart = getExpectedStart(testEvent, { ...baseState, offset: 0 }); + expect(getExpectedEnd(testEvent, expectedStart, baseState.currentDay)).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 + const expectedStart = getExpectedStart(testEvent, { ...baseState, currentDay: 0, offset: 0 }); + expect(getExpectedEnd(testEvent, expectedStart, baseState.currentDay)).toBe(150 + dayInMs); + + // when the running event is already on the same day, no extra day is added + const expectedStartSameDay = getExpectedStart( + { ...testEvent, dayOffset: 0 as Day }, + { ...baseState, currentDay: 0, offset: 0 }, + ); + expect(getExpectedEnd({ ...testEvent, dayOffset: 0 as Day }, expectedStartSameDay, baseState.currentDay)).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 + const expectedStartRegular = getExpectedStart({ ...testEvent }, relativeState); + expect(getExpectedEnd({ ...testEvent, countToEnd: false }, expectedStartRegular, relativeState.currentDay)).toBe( + 180, + ); + + // the countToEnd event stays pinned to its wall-clock end (150), not shifted + const expectedStartCountToEnd = getExpectedStart(testEvent, relativeState); + expect(getExpectedEnd(testEvent, expectedStartCountToEnd, relativeState.currentDay)).toBe(150); + }); +}); diff --git a/packages/utils/src/date-utils/getExpectedStart.ts b/packages/utils/src/date-utils/getExpected.ts similarity index 71% rename from packages/utils/src/date-utils/getExpectedStart.ts rename to packages/utils/src/date-utils/getExpected.ts index 716ccae0d..05b24bb42 100644 --- a/packages/utils/src/date-utils/getExpectedStart.ts +++ b/packages/utils/src/date-utils/getExpected.ts @@ -3,15 +3,6 @@ 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 - */ export function getExpectedStart( event: Pick, state: { @@ -60,3 +51,25 @@ export function getExpectedStart( const offsetStartTimeBufferedByGaps = offsetStartTime - totalGap; return offsetStartTimeBufferedByGaps; } + +export function getExpectedEnd( + event: Pick, + expectedStart: number, + currentRuntimeDay: number, +): number { + /** + * 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 - currentRuntimeDay; + 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; +}