mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-22 15:39:11 +00:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ACGuFWt5aN7Fv3AkYXgxLm
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
|||||||
MILLIS_PER_MINUTE,
|
MILLIS_PER_MINUTE,
|
||||||
MILLIS_PER_SECOND,
|
MILLIS_PER_SECOND,
|
||||||
formatFromMillis,
|
formatFromMillis,
|
||||||
|
getExpectedEnd,
|
||||||
getExpectedStart,
|
getExpectedStart,
|
||||||
} from 'ontime-utils';
|
} from 'ontime-utils';
|
||||||
|
|
||||||
@@ -172,13 +173,15 @@ export function getExpectedTimesFromExtendedEvent(
|
|||||||
) {
|
) {
|
||||||
if (event === null) return { expectedStart: 0, timeToStart: 0, expectedEnd: 0, plannedEnd: 0 };
|
if (event === null) return { expectedStart: 0, timeToStart: 0, expectedEnd: 0, plannedEnd: 0 };
|
||||||
|
|
||||||
|
const expectedStartState = {
|
||||||
|
totalGap: event.totalGap,
|
||||||
|
isLinkedToLoaded: event.isLinkedToLoaded,
|
||||||
|
...state,
|
||||||
|
};
|
||||||
|
|
||||||
const expectedStart = getExpectedStart(
|
const expectedStart = getExpectedStart(
|
||||||
{ timeStart: event.timeStart, delay: event.delay, dayOffset: event.dayOffset },
|
{ timeStart: event.timeStart, delay: event.delay, dayOffset: event.dayOffset },
|
||||||
{
|
expectedStartState,
|
||||||
totalGap: event.totalGap,
|
|
||||||
isLinkedToLoaded: event.isLinkedToLoaded,
|
|
||||||
...state,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const plannedEnd = event.timeStart + event.duration + event.delay;
|
const plannedEnd = event.timeStart + event.duration + event.delay;
|
||||||
@@ -186,9 +189,7 @@ export function getExpectedTimesFromExtendedEvent(
|
|||||||
return {
|
return {
|
||||||
expectedStart,
|
expectedStart,
|
||||||
timeToStart: expectedStart - state.clock,
|
timeToStart: expectedStart - state.clock,
|
||||||
expectedEnd: event.countToEnd
|
expectedEnd: getExpectedEnd(event, expectedStartState),
|
||||||
? Math.max(expectedStart + event.duration, plannedEnd)
|
|
||||||
: expectedStart + event.duration,
|
|
||||||
plannedEnd,
|
plannedEnd,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,6 +245,52 @@ describe('mutation on runtimeState', () => {
|
|||||||
expect(newState.offset.expectedRundownEnd).toBeNull();
|
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 () => {
|
test('resume restores currentDay from restore point', async () => {
|
||||||
clearState();
|
clearState();
|
||||||
const mockRundown = makeRundown({
|
const mockRundown = makeRundown({
|
||||||
@@ -956,4 +1002,32 @@ describe('loadGroupFlagAndEnd()', () => {
|
|||||||
eventNow: rundown.entries[0],
|
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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
calculateDuration,
|
calculateDuration,
|
||||||
checkIsNow,
|
checkIsNow,
|
||||||
dayInMs,
|
dayInMs,
|
||||||
|
getExpectedEnd,
|
||||||
getExpectedStart,
|
getExpectedStart,
|
||||||
getLastEventNormal,
|
getLastEventNormal,
|
||||||
isPlaybackActive,
|
isPlaybackActive,
|
||||||
@@ -836,7 +837,7 @@ function getExpectedTimes(state = runtimeState) {
|
|||||||
const { _group } = state;
|
const { _group } = state;
|
||||||
if (_group !== null) {
|
if (_group !== null) {
|
||||||
const { event: lastEvent, accumulatedGap, isLinkedToLoaded } = _group;
|
const { event: lastEvent, accumulatedGap, isLinkedToLoaded } = _group;
|
||||||
const lastEventExpectedStart = getExpectedStart(lastEvent, {
|
state.offset.expectedGroupEnd = getExpectedEnd(lastEvent, {
|
||||||
currentDay: state.rundown.currentDay!,
|
currentDay: state.rundown.currentDay!,
|
||||||
totalGap: accumulatedGap,
|
totalGap: accumulatedGap,
|
||||||
isLinkedToLoaded,
|
isLinkedToLoaded,
|
||||||
@@ -845,7 +846,6 @@ function getExpectedTimes(state = runtimeState) {
|
|||||||
plannedStart,
|
plannedStart,
|
||||||
actualStart,
|
actualStart,
|
||||||
});
|
});
|
||||||
state.offset.expectedGroupEnd = lastEventExpectedStart + lastEvent.duration;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -868,7 +868,7 @@ function getExpectedTimes(state = runtimeState) {
|
|||||||
|
|
||||||
if (state._end) {
|
if (state._end) {
|
||||||
const { event, accumulatedGap, isLinkedToLoaded } = state._end;
|
const { event, accumulatedGap, isLinkedToLoaded } = state._end;
|
||||||
const expectedStart = getExpectedStart(event, {
|
state.offset.expectedRundownEnd = getExpectedEnd(event, {
|
||||||
currentDay: state.rundown.currentDay!,
|
currentDay: state.rundown.currentDay!,
|
||||||
totalGap: accumulatedGap,
|
totalGap: accumulatedGap,
|
||||||
isLinkedToLoaded,
|
isLinkedToLoaded,
|
||||||
@@ -877,7 +877,6 @@ function getExpectedTimes(state = runtimeState) {
|
|||||||
plannedStart,
|
plannedStart,
|
||||||
actualStart,
|
actualStart,
|
||||||
});
|
});
|
||||||
state.offset.expectedRundownEnd = expectedStart + event.duration;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -920,6 +919,9 @@ export function loadGroupFlagAndEnd(
|
|||||||
|
|
||||||
let accumulatedGap = 0;
|
let accumulatedGap = 0;
|
||||||
let isLinkedToLoaded = true;
|
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++) {
|
for (let idx = currentIndex; idx < playableEventOrder.length; idx++) {
|
||||||
const entry = entries[playableEventOrder[idx]];
|
const entry = entries[playableEventOrder[idx]];
|
||||||
@@ -928,7 +930,7 @@ export function loadGroupFlagAndEnd(
|
|||||||
if (idx !== currentIndex) {
|
if (idx !== currentIndex) {
|
||||||
// we only accumulate data after the loaded event
|
// we only accumulate data after the loaded event
|
||||||
accumulatedGap += entry.gap;
|
accumulatedGap += entry.gap;
|
||||||
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart;
|
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart && !previousWasCountToEnd;
|
||||||
|
|
||||||
// and the loaded event is not allowed to be the next flag
|
// and the loaded event is not allowed to be the next flag
|
||||||
if (!foundFlag && metadata.flags.includes(entry.id)) {
|
if (!foundFlag && metadata.flags.includes(entry.id)) {
|
||||||
@@ -942,6 +944,9 @@ export function loadGroupFlagAndEnd(
|
|||||||
foundGroupEnd = true;
|
foundGroupEnd = true;
|
||||||
state._group = { event: lastEventInGroup, isLinkedToLoaded, accumulatedGap };
|
state._group = { event: lastEventInGroup, isLinkedToLoaded, accumulatedGap };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// carry the countToEnd status forward so the next event can break the chain
|
||||||
|
previousWasCountToEnd = entry.countToEnd;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export { validateEndAction, validateTimerType } from './src/validate-events/vali
|
|||||||
|
|
||||||
// feature business logic
|
// feature business logic
|
||||||
|
|
||||||
export { getExpectedStart } from './src/date-utils/getExpectedStart.js';
|
export { getExpectedEnd, getExpectedStart } from './src/date-utils/getExpectedStart.js';
|
||||||
|
|
||||||
// feature business logic - rundown
|
// feature business logic - rundown
|
||||||
export { checkIsNow } from './src/date-utils/checkIsNow.js';
|
export { checkIsNow } from './src/date-utils/checkIsNow.js';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Day, OffsetMode } from 'ontime-types';
|
import { Day, OffsetMode } from 'ontime-types';
|
||||||
|
|
||||||
import { MILLIS_PER_HOUR, dayInMs } from './conversionUtils';
|
import { MILLIS_PER_HOUR, dayInMs } from './conversionUtils';
|
||||||
import { getExpectedStart } from './getExpectedStart';
|
import { getExpectedEnd, getExpectedStart } from './getExpectedStart';
|
||||||
|
|
||||||
describe('getExpectedStart()', () => {
|
describe('getExpectedStart()', () => {
|
||||||
describe('Absolute offset mode', () => {
|
describe('Absolute offset mode', () => {
|
||||||
@@ -315,3 +315,70 @@ describe('getExpectedStart()', () => {
|
|||||||
expect(getExpectedStart(testEvent, { ...testState, currentDay: 0 })).toBe(23 * MILLIS_PER_HOUR + 5);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -60,3 +60,20 @@ export function getExpectedStart(
|
|||||||
const offsetStartTimeBufferedByGaps = offsetStartTime - totalGap;
|
const offsetStartTimeBufferedByGaps = offsetStartTime - totalGap;
|
||||||
return offsetStartTimeBufferedByGaps;
|
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<OntimeEvent, 'timeStart' | 'duration' | 'delay' | 'dayOffset' | 'countToEnd'>,
|
||||||
|
state: Parameters<typeof getExpectedStart>[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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user