From daf2cf67f4fd38f129162b22f24f96e4784f17ed Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sat, 20 Jun 2026 10:16:25 +0200 Subject: [PATCH] fix: count-to-end events break link chain --- apps/client/src/common/utils/time.ts | 17 +++-- .../src/stores/__tests__/runtimeState.test.ts | 74 +++++++++++++++++++ apps/server/src/stores/runtimeState.ts | 17 +++-- packages/utils/index.ts | 2 +- ...ectedStart.test.ts => getExpected.test.ts} | 69 ++++++++++++++++- .../{getExpectedStart.ts => getExpected.ts} | 34 ++++++++- 6 files changed, 195 insertions(+), 18 deletions(-) rename packages/utils/src/date-utils/{getExpectedStart.test.ts => getExpected.test.ts} (82%) rename packages/utils/src/date-utils/{getExpectedStart.ts => getExpected.ts} (65%) diff --git a/apps/client/src/common/utils/time.ts b/apps/client/src/common/utils/time.ts index 9990092ce..2b70d55d8 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,13 +173,15 @@ 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; @@ -186,9 +189,7 @@ export function getExpectedTimesFromExtendedEvent( return { expectedStart, timeToStart: expectedStart - state.clock, - expectedEnd: event.countToEnd - ? Math.max(expectedStart + event.duration, plannedEnd) - : expectedStart + event.duration, + expectedEnd: getExpectedEnd(event, expectedStartState), plannedEnd, }; } diff --git a/apps/server/src/stores/__tests__/runtimeState.test.ts b/apps/server/src/stores/__tests__/runtimeState.test.ts index 8b802e160..ba9c0c792 100644 --- a/apps/server/src/stores/__tests__/runtimeState.test.ts +++ b/apps/server/src/stores/__tests__/runtimeState.test.ts @@ -245,6 +245,52 @@ describe('mutation on runtimeState', () => { expect(newState.offset.expectedRundownEnd).toBeNull(); }); + test('a countToEnd last event absorbs overtime into its fixed rundown end', async () => { + const tenAM = 10 * MILLIS_PER_HOUR; + const elevenAM = 11 * MILLIS_PER_HOUR; + const noon = 12 * MILLIS_PER_HOUR; + + const entries = { + event1: { + ...mockEvent, + id: 'event1', + timeStart: tenAM, + timeEnd: elevenAM, + duration: MILLIS_PER_HOUR, + parent: null, + }, + event2: { + ...mockEvent, + id: 'event2', + timeStart: elevenAM, + timeEnd: noon, + 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 noon + 5min, but the countToEnd + // event absorbs the overtime so the rundown is still expected to end at noon + expect(newState.offset.expectedRundownEnd).toBe(noon); + }); + test('resume restores currentDay from restore point', async () => { clearState(); const mockRundown = makeRundown({ @@ -956,4 +1002,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 c65799c6e..63f1aa2fd 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, @@ -836,8 +837,8 @@ function getExpectedTimes(state = runtimeState) { const { _group } = state; if (_group !== null) { const { event: lastEvent, accumulatedGap, isLinkedToLoaded } = _group; - const lastEventExpectedStart = getExpectedStart(lastEvent, { - currentDay: state.rundown.currentDay!, + state.offset.expectedGroupEnd = getExpectedEnd(lastEvent, { + currentDay: state.rundown.currentDay ?? 0, totalGap: accumulatedGap, isLinkedToLoaded, mode: offset.mode, @@ -845,7 +846,6 @@ function getExpectedTimes(state = runtimeState) { plannedStart, actualStart, }); - state.offset.expectedGroupEnd = lastEventExpectedStart + lastEvent.duration; } } @@ -868,8 +868,8 @@ function getExpectedTimes(state = runtimeState) { if (state._end) { const { event, accumulatedGap, isLinkedToLoaded } = state._end; - const expectedStart = getExpectedStart(event, { - currentDay: state.rundown.currentDay!, + state.offset.expectedRundownEnd = getExpectedEnd(event, { + currentDay: state.rundown.currentDay ?? 0, totalGap: accumulatedGap, isLinkedToLoaded, mode: offset.mode, @@ -877,7 +877,6 @@ function getExpectedTimes(state = runtimeState) { plannedStart, actualStart, }); - state.offset.expectedRundownEnd = expectedStart + event.duration; } } @@ -920,6 +919,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]]; @@ -928,7 +929,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)) { @@ -942,6 +943,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 82% rename from packages/utils/src/date-utils/getExpectedStart.test.ts rename to packages/utils/src/date-utils/getExpected.test.ts index b24722cdc..51352dd15 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,70 @@ 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('a countToEnd event pins to the planned end while ahead of schedule', () => { + const testEvent = { + timeStart: 100, + duration: 50, + delay: 0, + dayOffset: 0 as Day, + countToEnd: true, + }; + + // ahead of schedule the start moves earlier (90) but the end stays pinned to 150 + expect(getExpectedEnd(testEvent, { ...baseState, offset: -10 })).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); + }); +}); diff --git a/packages/utils/src/date-utils/getExpectedStart.ts b/packages/utils/src/date-utils/getExpected.ts similarity index 65% rename from packages/utils/src/date-utils/getExpectedStart.ts rename to packages/utils/src/date-utils/getExpected.ts index 716ccae0d..f6e6359a9 100644 --- a/packages/utils/src/date-utils/getExpectedStart.ts +++ b/packages/utils/src/date-utils/getExpected.ts @@ -8,7 +8,6 @@ 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 */ @@ -60,3 +59,36 @@ export function getExpectedStart( const offsetStartTimeBufferedByGaps = offsetStartTime - totalGap; return offsetStartTimeBufferedByGaps; } + +/** + * @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 offset + * @returns + */ +export function getExpectedEnd( + event: Pick, + state: { + currentDay: number; // the current day from the rundown + totalGap: number; + isLinkedToLoaded: boolean; + offset: number; + mode: OffsetMode; + actualStart: MaybeNumber; + plannedStart: MaybeNumber; + }, +): number { + // expected start encodes the offset from current delays + const expectedStart = getExpectedStart(event, state); + + // count to end events should finish on schedule unlesss the start is compromised + if (event.countToEnd) { + // count to end take scheduled delays into consideration + const plannedEnd = event.timeStart + event.duration + event.delay; + return Math.max(expectedStart, plannedEnd); + } + + return expectedStart + event.duration; +}