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:
Claude
2026-06-20 06:44:06 +00:00
parent c238147b75
commit 5b30f522aa
6 changed files with 179 additions and 15 deletions
+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,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);
});
});
@@ -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<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;
}