From 5b30f522aa0b97aa7cfe8304c9df7f61724c5b02 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 06:44:06 +0000 Subject: [PATCH] fix(timer): count-to-end events absorb overtime in expected end times The server computed rundown/group expected end times as `expectedStart + duration`, ignoring `countToEnd`. For a count-to-end last event running in overtime this pushed the projected end far past (or before) its fixed end time, producing nonsensical values (e.g. a negative expected rundown end) and leaving group/flag readouts stuck on "due". It also never broke the link chain after a count-to-end event, unlike the client. A count-to-end event ends at its fixed end time regardless of preceding overtime (matching `getExpectedFinish` for the running timer), so its expected end should pin to the planned end and absorb the accumulated offset. - add shared `getExpectedEnd` helper in ontime-utils that pins count-to-end events to their planned end - use it in `getExpectedTimes` for both rundown and group end - break the `isLinkedToLoaded` chain after a count-to-end event in `loadGroupFlagAndEnd`, mirroring the client metadata logic - delegate the client's `getExpectedTimesFromExtendedEvent` to the shared helper to remove the duplicated formula that caused the drift - add unit and runtime-state tests covering overtime absorption and the chain break Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ACGuFWt5aN7Fv3AkYXgxLm --- apps/client/src/common/utils/time.ts | 17 +++-- .../src/stores/__tests__/runtimeState.test.ts | 74 +++++++++++++++++++ apps/server/src/stores/runtimeState.ts | 15 ++-- packages/utils/index.ts | 2 +- .../src/date-utils/getExpectedStart.test.ts | 69 ++++++++++++++++- .../utils/src/date-utils/getExpectedStart.ts | 17 +++++ 6 files changed, 179 insertions(+), 15 deletions(-) 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..23b7d80b5 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,7 +837,7 @@ function getExpectedTimes(state = runtimeState) { const { _group } = state; if (_group !== null) { const { event: lastEvent, accumulatedGap, isLinkedToLoaded } = _group; - const lastEventExpectedStart = getExpectedStart(lastEvent, { + state.offset.expectedGroupEnd = getExpectedEnd(lastEvent, { currentDay: state.rundown.currentDay!, totalGap: accumulatedGap, isLinkedToLoaded, @@ -845,7 +846,6 @@ function getExpectedTimes(state = runtimeState) { plannedStart, actualStart, }); - state.offset.expectedGroupEnd = lastEventExpectedStart + lastEvent.duration; } } @@ -868,7 +868,7 @@ function getExpectedTimes(state = runtimeState) { 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, @@ -877,7 +877,6 @@ function getExpectedTimes(state = runtimeState) { plannedStart, actualStart, }); - state.offset.expectedRundownEnd = expectedStart + event.duration; } } @@ -920,6 +919,9 @@ export function loadGroupFlagAndEnd( let accumulatedGap = 0; let isLinkedToLoaded = true; + // a countToEnd event absorbs overtime, so the chain breaks on the event that follows it + // mirrors the client logic in common/utils/rundownMetadata.ts + let previousWasCountToEnd = false; for (let idx = currentIndex; idx < playableEventOrder.length; idx++) { const entry = entries[playableEventOrder[idx]]; @@ -928,7 +930,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 +944,9 @@ export function loadGroupFlagAndEnd( foundGroupEnd = true; state._group = { event: lastEventInGroup, isLinkedToLoaded, accumulatedGap }; } + + // carry the countToEnd status forward so the next event can break the chain + previousWasCountToEnd = entry.countToEnd; } } diff --git a/packages/utils/index.ts b/packages/utils/index.ts index 6ba939175..333d077c3 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 { getExpectedEnd, getExpectedStart } from './src/date-utils/getExpectedStart.js'; // feature business logic - rundown export { checkIsNow } from './src/date-utils/checkIsNow.js'; diff --git a/packages/utils/src/date-utils/getExpectedStart.test.ts b/packages/utils/src/date-utils/getExpectedStart.test.ts index b24722cdc..b97e78ac0 100644 --- a/packages/utils/src/date-utils/getExpectedStart.test.ts +++ b/packages/utils/src/date-utils/getExpectedStart.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 './getExpectedStart'; 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/getExpectedStart.ts index 716ccae0d..1bf46a598 100644 --- a/packages/utils/src/date-utils/getExpectedStart.ts +++ b/packages/utils/src/date-utils/getExpectedStart.ts @@ -60,3 +60,20 @@ export function getExpectedStart( const offsetStartTimeBufferedByGaps = offsetStartTime - totalGap; return offsetStartTimeBufferedByGaps; } + +/** + * Computes the normalised expected end of an event. + * A countToEnd event ends at its fixed end time regardless of how much overtime + * precedes it, so its expected end is pinned to the planned end - it absorbs the + * accumulated offset (mirrors getExpectedFinish in the running timer). The end can + * only drift later if the event is projected to start after its own end time. + * The result lives in the same day-normalised space as getExpectedStart (it may exceed dayInMs). + */ +export function getExpectedEnd( + event: Pick, + state: Parameters[1], +): number { + const expectedStart = getExpectedStart(event, state); + const plannedEnd = event.timeStart + event.duration + event.delay; + return event.countToEnd ? Math.max(expectedStart, plannedEnd) : expectedStart + event.duration; +}