Compare commits

...

2 Commits

Author SHA1 Message Date
Claude c169c419c3 fix(timer): correct count-to-end expected end across modes and days
The first pass pinned the count-to-end end with
`Math.max(expectedStart, plannedEnd)`, but those operands live in
different coordinate spaces: `getExpectedStart` bakes in the day shift,
the relative-start shift and the live runtime offset, while `plannedEnd`
was raw time-of-day. The result was only correct in single-day Absolute
mode and drifted in Relative mode and across day boundaries.

A count-to-end event is anchored to its fixed wall-clock end, so its
expected end is the scheduled end normalised for the day, with no runtime
offset and no relative-start shift (matching getExpectedFinish, which
returns the raw timeEnd). Compute it directly as
`normalisedTimeStart + duration`.

Add relative-mode and multi-day unit tests covering the cases the
previous formula got wrong.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACGuFWt5aN7Fv3AkYXgxLm
2026-06-21 08:07:33 +00:00
Claude 5b30f522aa 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
2026-06-20 06:44:06 +00:00
6 changed files with 223 additions and 15 deletions
+9 -8
View File
@@ -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,
};
}
@@ -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 });
});
});
+10 -5
View File
@@ -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;
}
}
+1 -1
View File
@@ -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';
@@ -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,110 @@ 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);
});
test('a countToEnd event is NOT shifted by the relative-start offset', () => {
const testEvent = {
timeStart: 100,
duration: 50,
delay: 0,
dayOffset: 0 as Day,
countToEnd: true,
};
// in relative mode a regular event would be shifted by actualStart - plannedStart (+30),
// but a countToEnd event is anchored to its wall-clock end and stays at 150
const relativeState = {
...baseState,
mode: OffsetMode.Relative,
actualStart: 30,
plannedStart: 0,
offset: 0,
};
// sanity: a regular event in the same state is shifted to 180
expect(getExpectedEnd({ ...testEvent, countToEnd: false }, relativeState)).toBe(180);
// the countToEnd event is not shifted
expect(getExpectedEnd(testEvent, relativeState)).toBe(150);
});
test('a countToEnd event on a later day adds the day offset', () => {
const testEvent = {
timeStart: 100,
duration: 50,
delay: 0,
dayOffset: 1 as Day,
countToEnd: true,
};
// dayOffset 1 with currentDay 0 -> end normalised one day forward
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);
});
});
@@ -60,3 +60,24 @@ export function getExpectedStart(
const offsetStartTimeBufferedByGaps = offsetStartTime - totalGap;
return offsetStartTimeBufferedByGaps;
}
/**
* Computes the normalised expected end of an event.
* A countToEnd event is anchored to its fixed wall-clock end: it absorbs the accumulated
* runtime offset and is not shifted by the relative-start offset, so its expected end is the
* scheduled end normalised for the day (mirrors getExpectedFinish in the running timer, which
* returns the raw timeEnd). A regular event's end moves with the runtime offset.
* 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 {
if (!event.countToEnd) {
return getExpectedStart(event, state) + event.duration;
}
const delayedStart = Math.max(0, event.timeStart + event.delay);
const relativeDayOffset = event.dayOffset - state.currentDay;
return delayedStart + relativeDayOffset * dayInMs + event.duration;
}