mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-18 13:44:12 +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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user