mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
fix: count-to-end events break link chain
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
MILLIS_PER_MINUTE,
|
||||
MILLIS_PER_SECOND,
|
||||
formatFromMillis,
|
||||
getExpectedEnd,
|
||||
getExpectedStart,
|
||||
} from 'ontime-utils';
|
||||
|
||||
@@ -172,23 +173,28 @@ 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;
|
||||
// count to end events are fixed to the scheduled end and ignore delays
|
||||
const delayToAdd = event.countToEnd ? 0 : event.delay;
|
||||
const plannedEnd = event.timeStart + event.duration + delayToAdd;
|
||||
|
||||
// we let timeToStart go negative to allow the UI to show due timers
|
||||
const timeToStart = expectedStart - state.clock;
|
||||
|
||||
return {
|
||||
expectedStart,
|
||||
timeToStart: expectedStart - state.clock,
|
||||
expectedEnd: event.countToEnd
|
||||
? Math.max(expectedStart + event.duration, plannedEnd)
|
||||
: expectedStart + event.duration,
|
||||
timeToStart,
|
||||
expectedEnd: getExpectedEnd(event, expectedStartState),
|
||||
plannedEnd,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -151,9 +151,8 @@ type ScheduleTimeProps = {
|
||||
showExpected: boolean;
|
||||
};
|
||||
//TODO: consider relative mode
|
||||
export function ScheduleTime(props: ScheduleTimeProps) {
|
||||
const { event, showExpected } = props;
|
||||
const { timeStart, duration, delay, expectedStart, countToEnd } = event;
|
||||
export function ScheduleTime({ event, showExpected }: ScheduleTimeProps) {
|
||||
const { timeStart, duration, delay, expectedStart, expectedEnd, countToEnd } = event;
|
||||
|
||||
const plannedStart = timeStart + delay + event.dayOffset * dayInMs;
|
||||
|
||||
@@ -163,8 +162,14 @@ export function ScheduleTime(props: ScheduleTimeProps) {
|
||||
const plannedStateClass = isExpectedValueShow ? 'sub__schedule--strike' : delay !== 0 ? 'sub__schedule--delayed' : '';
|
||||
|
||||
const expectedStateClass = `sub__schedule--${getOffsetState(expectedStart - plannedStart)}`;
|
||||
const plannedEnd = plannedStart + duration + delay;
|
||||
const expectedEnd = countToEnd ? Math.max(expectedStart + duration, plannedEnd) : expectedStart + duration;
|
||||
|
||||
// count to end events are fixed to the scheduled end and ignore delays
|
||||
const plannedEnd = (() => {
|
||||
if (countToEnd) {
|
||||
return timeStart + event.dayOffset * dayInMs + duration;
|
||||
}
|
||||
return plannedStart + duration;
|
||||
})();
|
||||
const expectedEndClass = `sub__schedule--${getOffsetState(expectedEnd - plannedEnd)}`;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { MaybeNumber, OntimeEvent } from 'ontime-types';
|
||||
import { getExpectedStart } from 'ontime-utils';
|
||||
import { IoPencil } from 'react-icons/io5';
|
||||
|
||||
import Button from '../../common/components/buttons/Button';
|
||||
@@ -11,7 +10,7 @@ import { cx } from '../../common/utils/styleUtils';
|
||||
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
||||
import { getPropertyValue } from '../common/viewUtils';
|
||||
import { useCountdownOptions } from './countdown.options';
|
||||
import { CountdownTarget, useSubscriptionDisplayData } from './countdown.utils';
|
||||
import { CountdownTarget, extendEventData, useSubscriptionDisplayData } from './countdown.utils';
|
||||
import { ScheduleTime } from './CountdownSubscriptions';
|
||||
|
||||
import './SingleEventCountdown.scss';
|
||||
@@ -27,19 +26,15 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
|
||||
const { data: reportData } = useReport();
|
||||
|
||||
const { offset, currentDay, actualStart, plannedStart, mode } = useExpectedStartData();
|
||||
const { totalGap, isLinkedToLoaded } = subscribedEvent;
|
||||
const expectedStart = getExpectedStart(subscribedEvent, {
|
||||
const countdownEvent = extendEventData(
|
||||
subscribedEvent,
|
||||
currentDay,
|
||||
totalGap,
|
||||
actualStart,
|
||||
plannedStart,
|
||||
isLinkedToLoaded,
|
||||
offset,
|
||||
mode,
|
||||
});
|
||||
|
||||
const { endedAt } = reportData[subscribedEvent.reportId ?? subscribedEvent.id] ?? { endedAt: null };
|
||||
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt };
|
||||
reportData,
|
||||
);
|
||||
const titleTmp = getPropertyValue(subscribedEvent, mainSource ?? 'title');
|
||||
const title = titleTmp?.length ? titleTmp : ' '; // insert utf-8 empty space to avoid the line collapsing;
|
||||
// while a group is live, surface the running event's title as the secondary line
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
isOntimeGroup,
|
||||
isPlayableEvent,
|
||||
} from 'ontime-types';
|
||||
import { MILLIS_PER_MINUTE, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
|
||||
import { MILLIS_PER_MINUTE, getExpectedEnd, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
|
||||
|
||||
import { useCountdownSocket } from '../../common/hooks/useSocket';
|
||||
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||
@@ -197,7 +197,11 @@ export type CountdownTarget = ExtendedEntry<OntimeEvent> & {
|
||||
liveEntry?: ExtendedEntry<OntimeEvent> | null; // the running child while a group is live
|
||||
};
|
||||
|
||||
export type CountdownEvent = CountdownTarget & { expectedStart: number; endedAt: MaybeNumber };
|
||||
export type CountdownEvent = CountdownTarget & {
|
||||
expectedStart: number;
|
||||
expectedEnd: number;
|
||||
endedAt: MaybeNumber;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves a subscription (event or group) into an event-shaped countdown target.
|
||||
@@ -262,7 +266,7 @@ export function extendEventData(
|
||||
reportData: OntimeReport,
|
||||
): CountdownEvent {
|
||||
const { totalGap, isLinkedToLoaded } = event;
|
||||
const expectedStart = getExpectedStart(event, {
|
||||
const expectedStartState = {
|
||||
currentDay,
|
||||
totalGap,
|
||||
actualStart,
|
||||
@@ -270,7 +274,9 @@ export function extendEventData(
|
||||
isLinkedToLoaded,
|
||||
offset,
|
||||
mode,
|
||||
});
|
||||
};
|
||||
const expectedStart = getExpectedStart(event, expectedStartState);
|
||||
const expectedEnd = getExpectedEnd(event, expectedStartState);
|
||||
const { endedAt } = reportData[event.reportId ?? event.id] ?? { endedAt: null };
|
||||
return { ...event, expectedStart, endedAt };
|
||||
return { ...event, expectedStart, expectedEnd, endedAt };
|
||||
}
|
||||
|
||||
@@ -7,11 +7,21 @@ import { initRundown } from '../../api-data/rundown/rundown.service.js';
|
||||
import { loadRoll } from '../rollUtils.js';
|
||||
|
||||
vi.mock('../../classes/data-provider/DataProvider.js', () => {
|
||||
let automation = {
|
||||
enabledAutomations: false,
|
||||
enabledOscIn: false,
|
||||
oscPortIn: 8888,
|
||||
triggers: [],
|
||||
automations: {},
|
||||
};
|
||||
|
||||
return {
|
||||
getDataProvider: vi.fn().mockImplementation(() => {
|
||||
return {
|
||||
getAutomation: vi.fn().mockImplementation(() => automation),
|
||||
setCustomFields: vi.fn().mockImplementation((newData) => newData),
|
||||
setRundown: vi.fn().mockImplementation((newData) => newData),
|
||||
setAutomation: vi.fn().mockImplementation((newData) => (automation = newData)),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -225,7 +225,7 @@ describe('getExpectedFinish()', () => {
|
||||
expect(calculatedFinish).toBe(10);
|
||||
});
|
||||
describe('on timers of type time-to-end', () => {
|
||||
it('finish time is as schedule + added time', () => {
|
||||
it('finish time is the fixed end, ignoring added time', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
timeEnd: 30,
|
||||
@@ -242,8 +242,31 @@ describe('getExpectedFinish()', () => {
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
// the end is anchored: added time surfaces as offset, it does not move the finish
|
||||
const calculatedFinish = getExpectedFinish(state);
|
||||
expect(calculatedFinish).toBe(40);
|
||||
expect(calculatedFinish).toBe(30);
|
||||
});
|
||||
|
||||
it('finish time is the fixed end, ignoring pauses', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
timeEnd: 30,
|
||||
countToEnd: true,
|
||||
},
|
||||
clock: 25,
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
duration: dayInMs,
|
||||
startedAt: 10,
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: 20, // paused 5 ago - wall clock keeps approaching the fixed end
|
||||
hasFinished: false,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
const calculatedFinish = getExpectedFinish(state);
|
||||
expect(calculatedFinish).toBe(30);
|
||||
});
|
||||
it('handles events that finish the day after', () => {
|
||||
const state = {
|
||||
@@ -451,7 +474,7 @@ describe('getCurrent()', () => {
|
||||
expect(current).toBe(70);
|
||||
});
|
||||
|
||||
it('current time is the time to end + added time', () => {
|
||||
it('current time is the time to end, ignoring added time', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
timeEnd: 100,
|
||||
@@ -472,8 +495,9 @@ describe('getCurrent()', () => {
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
// counts to the fixed end; added time surfaces as offset, not extra countdown
|
||||
const current = getCurrent(state);
|
||||
expect(current).toBe(77);
|
||||
expect(current).toBe(70);
|
||||
});
|
||||
|
||||
it('handles events that finish the day after', () => {
|
||||
@@ -1073,7 +1097,7 @@ describe('getRuntimeOffset()', () => {
|
||||
expect(absolute).toBe(0);
|
||||
});
|
||||
|
||||
it('with time-to-end, offset is the overtime', () => {
|
||||
it('with time-to-end, offset combines overtime and added time', () => {
|
||||
const state = {
|
||||
clock: 82000000, // 22:46:40
|
||||
eventNow: {
|
||||
@@ -1126,7 +1150,45 @@ describe('getRuntimeOffset()', () => {
|
||||
} as RuntimeState;
|
||||
|
||||
const { absolute } = getRuntimeOffset(state);
|
||||
expect(absolute).toBe(400000); // <--- offset is always the overtime
|
||||
// overtime (400000) plus the operator's added time (-200000)
|
||||
expect(absolute).toBe(200000);
|
||||
});
|
||||
|
||||
it('with time-to-end, added time surfaces as offset', () => {
|
||||
const state = {
|
||||
clock: 80000000, // 22:13:20 - before the scheduled end, not in overtime
|
||||
eventNow: {
|
||||
id: 'd6a2ce',
|
||||
timeStart: 77400000, // 21:30:00
|
||||
timeEnd: 81000000, // 22:30:00
|
||||
duration: 3600000, // 01:00:00
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
countToEnd: true,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
},
|
||||
rundown: {
|
||||
plannedStart: 77400000, // 21:30:00
|
||||
plannedEnd: 81000000, // 22:30:00
|
||||
actualStart: 78000000, // 21:40:00
|
||||
currentDay: 0,
|
||||
},
|
||||
offset: {
|
||||
absolute: 0,
|
||||
},
|
||||
timer: {
|
||||
addedTime: 300000, // operator added 5 minutes
|
||||
current: 1000000, // still counting down, no overtime
|
||||
duration: 3600000,
|
||||
startedAt: 78000000,
|
||||
},
|
||||
_startDayOffset: 0,
|
||||
_timer: { pausedAt: null },
|
||||
} as RuntimeState;
|
||||
|
||||
// the end is anchored, so the added 5 minutes shows up purely as offset
|
||||
const { absolute } = getRuntimeOffset(state);
|
||||
expect(absolute).toBe(300000);
|
||||
});
|
||||
|
||||
it('handles time-to-end started after the end time', () => {
|
||||
|
||||
@@ -41,7 +41,9 @@ export function getExpectedFinish(state: RuntimeState): MaybeNumber {
|
||||
const pausedTime = pausedAt != null ? clock - pausedAt : 0;
|
||||
|
||||
if (countToEnd) {
|
||||
return timeEnd + addedTime + pausedTime;
|
||||
// count to end events are anchored to their fixed end: added time and pauses
|
||||
// do not move the end, they surface as offset instead (see getRuntimeOffset)
|
||||
return timeEnd;
|
||||
}
|
||||
|
||||
// handle events that finish the day after
|
||||
@@ -74,9 +76,10 @@ export function getCurrent(state: RuntimeState): number {
|
||||
const { clock } = state;
|
||||
|
||||
if (countToEnd) {
|
||||
// count to end runs to its fixed end, so added time does not stretch the countdown
|
||||
const isEventOverMidnight = timeStart > timeEnd;
|
||||
const correctDay = isEventOverMidnight ? dayInMs : 0;
|
||||
return correctDay - clock + timeEnd + addedTime;
|
||||
return correctDay - clock + timeEnd;
|
||||
}
|
||||
|
||||
if (startedAt === null) {
|
||||
@@ -180,13 +183,14 @@ export function getRuntimeOffset(state: RuntimeState): { absolute: number; relat
|
||||
const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt;
|
||||
|
||||
// absolute offset is difference between schedule and playback time
|
||||
const absolute = eventStartOffset + overtime + pausedTime + addedTime;
|
||||
// count to end is anchored to its fixed end, so it absorbs the late start (eventStartOffset)
|
||||
// and the pause (already reflected in overtime); added time surfaces here as offset
|
||||
const absolute = countToEnd ? overtime + addedTime : eventStartOffset + overtime + pausedTime + addedTime;
|
||||
|
||||
// the relative offset is the same as the absolute but adjusted relative to the actual start time
|
||||
const relative = absolute + plannedStart - actualStart - _startDayOffset * dayInMs;
|
||||
|
||||
// in case of count to end, the absolute offset is just the overtime
|
||||
return countToEnd ? { absolute: overtime, relative } : { absolute, relative };
|
||||
return { absolute, relative };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -56,18 +56,21 @@ const mockState = {
|
||||
} as RuntimeState;
|
||||
|
||||
vi.mock('../../classes/data-provider/DataProvider.js', () => {
|
||||
let automation = {
|
||||
enabledAutomations: false,
|
||||
enabledOscIn: false,
|
||||
oscPortIn: 8888,
|
||||
triggers: [],
|
||||
automations: {},
|
||||
};
|
||||
|
||||
return {
|
||||
getDataProvider: vi.fn().mockImplementation(() => {
|
||||
return {
|
||||
getAutomation: vi.fn().mockImplementation(() => automation),
|
||||
setCustomFields: vi.fn().mockImplementation((newData) => newData),
|
||||
setRundown: vi.fn().mockImplementation((newData) => newData),
|
||||
getAutomation: vi.fn().mockReturnValue({
|
||||
enabledAutomations: false,
|
||||
enabledOscIn: false,
|
||||
oscPortIn: 0,
|
||||
triggers: [],
|
||||
automations: {},
|
||||
}),
|
||||
setAutomation: vi.fn().mockImplementation((newData) => (automation = newData)),
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -325,6 +328,121 @@ describe('mutation on runtimeState', () => {
|
||||
expect(newState.offset.expectedRundownEnd).toBeNull();
|
||||
});
|
||||
|
||||
test('a countToEnd last event absorbs overtime into its fixed rundown end', async () => {
|
||||
const entries = {
|
||||
event1: {
|
||||
...mockEvent,
|
||||
id: 'event1',
|
||||
timeStart: 10 * MILLIS_PER_HOUR,
|
||||
timeEnd: 11 * MILLIS_PER_HOUR,
|
||||
duration: MILLIS_PER_HOUR,
|
||||
parent: null,
|
||||
},
|
||||
event2: {
|
||||
...mockEvent,
|
||||
id: 'event2',
|
||||
timeStart: 11 * MILLIS_PER_HOUR,
|
||||
timeEnd: 12 * MILLIS_PER_HOUR,
|
||||
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 12h + 5min, but the countToEnd
|
||||
// event absorbs the overtime so the rundown is still expected to end at 12h
|
||||
expect(newState.offset.expectedRundownEnd).toBe(12 * MILLIS_PER_HOUR);
|
||||
});
|
||||
|
||||
test('adding time to a running countToEnd event surfaces as offset and end remains fixed', async () => {
|
||||
const entries = {
|
||||
event1: {
|
||||
...mockEvent,
|
||||
id: 'event1',
|
||||
timeStart: 10 * MILLIS_PER_HOUR,
|
||||
timeEnd: 12 * MILLIS_PER_HOUR,
|
||||
duration: 2 * MILLIS_PER_HOUR,
|
||||
countToEnd: true,
|
||||
parent: null,
|
||||
},
|
||||
};
|
||||
const mockRundown = makeRundown({ entries, order: ['event1'] });
|
||||
|
||||
await initRundown(mockRundown, {});
|
||||
vi.runAllTimers();
|
||||
|
||||
const { metadata, rundown } = rundownCache.get();
|
||||
|
||||
// start on time
|
||||
vi.setSystemTime('jan 1 10:00');
|
||||
load(entries.event1, rundown, metadata);
|
||||
start();
|
||||
update();
|
||||
|
||||
let state = getState();
|
||||
expect(state.offset.absolute).toBe(0);
|
||||
expect(state.timer.expectedFinish).toBe(12 * MILLIS_PER_HOUR);
|
||||
expect(state.offset.expectedRundownEnd).toBe(12 * MILLIS_PER_HOUR);
|
||||
|
||||
// operator adds 5 minutes
|
||||
addTime(5 * MILLIS_PER_MINUTE);
|
||||
|
||||
state = getState();
|
||||
// the end cant move, so the added time is added to the offset
|
||||
expect(state.offset.absolute).toBe(5 * MILLIS_PER_MINUTE);
|
||||
expect(state.timer.expectedFinish).toBe(12 * MILLIS_PER_HOUR);
|
||||
expect(state.offset.expectedRundownEnd).toBe(12 * MILLIS_PER_HOUR);
|
||||
});
|
||||
|
||||
test('subtracting more than the remaining time does not finish a countToEnd event', async () => {
|
||||
const entries = {
|
||||
event1: {
|
||||
...mockEvent,
|
||||
id: 'event1',
|
||||
timeStart: 10 * MILLIS_PER_HOUR,
|
||||
timeEnd: 12 * MILLIS_PER_HOUR,
|
||||
duration: 2 * MILLIS_PER_HOUR,
|
||||
countToEnd: true,
|
||||
parent: null,
|
||||
},
|
||||
};
|
||||
const mockRundown = makeRundown({ entries, order: ['event1'] });
|
||||
|
||||
await initRundown(mockRundown, {});
|
||||
vi.runAllTimers();
|
||||
|
||||
const { metadata, rundown } = rundownCache.get();
|
||||
|
||||
vi.setSystemTime('jan 1 10:00');
|
||||
load(entries.event1, rundown, metadata);
|
||||
start();
|
||||
update();
|
||||
|
||||
// removing longer than the remaining time cannot finish the event since the end is fixed
|
||||
addTime(-3 * MILLIS_PER_HOUR);
|
||||
update();
|
||||
|
||||
const state = getState();
|
||||
expect(state.timer.playback).toBe(Playback.Play);
|
||||
expect(state.timer.expectedFinish).toBe(12 * MILLIS_PER_HOUR);
|
||||
});
|
||||
|
||||
test('resume restores currentDay from restore point', async () => {
|
||||
clearState();
|
||||
const mockRundown = makeRundown({
|
||||
@@ -1036,4 +1154,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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
calculateDuration,
|
||||
checkIsNow,
|
||||
dayInMs,
|
||||
getExpectedEnd,
|
||||
getExpectedStart,
|
||||
getLastEventNormal,
|
||||
isPlaybackActive,
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
hasCrossedMidnight,
|
||||
} from '../services/timerUtils.js';
|
||||
import { timerConfig } from '../setup/config.js';
|
||||
import { shouldCrashDev } from '../utils/development.js';
|
||||
|
||||
type ExpectedMetadata = {
|
||||
event: OntimeEvent;
|
||||
@@ -509,23 +511,31 @@ export function addTime(amount: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// handle edge cases
|
||||
// !!! we need to handle side effects before updating the state
|
||||
const willGoNegative = amount < 0 && Math.abs(amount) > runtimeState.timer.current;
|
||||
|
||||
if (willGoNegative && !runtimeState._timer.hasFinished) {
|
||||
// set finished time so side effects are triggered
|
||||
runtimeState._timer.forceFinish = timeCore.timeOfDayNow();
|
||||
if (runtimeState.eventNow?.countToEnd) {
|
||||
// count to end is anchored to its fixed end: added time cannot move the end or finish
|
||||
// the event early, it only surfaces as offset. `current` is derived from the wall clock
|
||||
// by the update loop, so we must not bump it here.
|
||||
runtimeState.timer.addedTime += amount;
|
||||
} else {
|
||||
const willGoPositive = runtimeState.timer.current < 0 && runtimeState.timer.current + amount > 0;
|
||||
if (willGoPositive) {
|
||||
runtimeState._timer.hasFinished = false;
|
||||
// handle edge cases
|
||||
// !!! we need to handle side effects before updating the state
|
||||
const willGoNegative = amount < 0 && Math.abs(amount) > runtimeState.timer.current;
|
||||
|
||||
if (willGoNegative && !runtimeState._timer.hasFinished) {
|
||||
// set finished time so side effects are triggered
|
||||
runtimeState._timer.forceFinish = timeCore.timeOfDayNow();
|
||||
} else {
|
||||
const willGoPositive = runtimeState.timer.current < 0 && runtimeState.timer.current + amount > 0;
|
||||
if (willGoPositive) {
|
||||
runtimeState._timer.hasFinished = false;
|
||||
}
|
||||
}
|
||||
|
||||
// we can update the state after handling the side effects
|
||||
runtimeState.timer.addedTime += amount;
|
||||
runtimeState.timer.current += amount;
|
||||
}
|
||||
|
||||
// we can update the state after handling the side effects
|
||||
runtimeState.timer.addedTime += amount;
|
||||
runtimeState.timer.current += amount;
|
||||
runtimeState.timer.elapsed = getElapsed(runtimeState);
|
||||
|
||||
// update runtime delays: over - under
|
||||
@@ -831,7 +841,6 @@ function getExpectedTimes(state = runtimeState) {
|
||||
state.offset.expectedRundownEnd = null;
|
||||
state.offset.expectedGroupEnd = null;
|
||||
state.offset.expectedFlagStart = null;
|
||||
state.offset.expectedRundownEnd = null;
|
||||
|
||||
const { offset } = state;
|
||||
const { plannedStart, actualStart } = state.rundown;
|
||||
@@ -841,9 +850,11 @@ function getExpectedTimes(state = runtimeState) {
|
||||
|
||||
if (state.groupNow) {
|
||||
const { _group } = state;
|
||||
if (_group !== null) {
|
||||
const { event: lastEvent, accumulatedGap, isLinkedToLoaded } = _group;
|
||||
const lastEventExpectedStart = getExpectedStart(lastEvent, {
|
||||
DEV: shouldCrashDev(_group === null, 'groupNow is set but _group is null');
|
||||
|
||||
if (_group) {
|
||||
const { event, accumulatedGap, isLinkedToLoaded } = _group;
|
||||
state.offset.expectedGroupEnd = getExpectedEnd(event, {
|
||||
currentDay: state.rundown.currentDay!,
|
||||
totalGap: accumulatedGap,
|
||||
isLinkedToLoaded,
|
||||
@@ -852,15 +863,16 @@ function getExpectedTimes(state = runtimeState) {
|
||||
plannedStart,
|
||||
actualStart,
|
||||
});
|
||||
state.offset.expectedGroupEnd = lastEventExpectedStart + lastEvent.duration;
|
||||
}
|
||||
}
|
||||
|
||||
if (state.eventFlag) {
|
||||
const { _flag } = state;
|
||||
DEV: shouldCrashDev(_flag === null, 'eventFlag is set but _flag is null');
|
||||
|
||||
if (_flag) {
|
||||
const { event, accumulatedGap, isLinkedToLoaded } = _flag;
|
||||
const expectedStart = getExpectedStart(event, {
|
||||
state.offset.expectedFlagStart = getExpectedStart(event, {
|
||||
currentDay: state.rundown.currentDay!,
|
||||
totalGap: accumulatedGap,
|
||||
isLinkedToLoaded,
|
||||
@@ -869,13 +881,13 @@ function getExpectedTimes(state = runtimeState) {
|
||||
plannedStart,
|
||||
actualStart,
|
||||
});
|
||||
state.offset.expectedFlagStart = expectedStart;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -884,7 +896,6 @@ function getExpectedTimes(state = runtimeState) {
|
||||
plannedStart,
|
||||
actualStart,
|
||||
});
|
||||
state.offset.expectedRundownEnd = expectedStart + event.duration;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -927,6 +938,8 @@ export function loadGroupFlagAndEnd(
|
||||
|
||||
let accumulatedGap = 0;
|
||||
let isLinkedToLoaded = true;
|
||||
// a countToEnd event absorbs overtime and breaks the chain
|
||||
let previousWasCountToEnd = false;
|
||||
|
||||
for (let idx = currentIndex; idx < playableEventOrder.length; idx++) {
|
||||
const entry = entries[playableEventOrder[idx]];
|
||||
@@ -935,7 +948,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)) {
|
||||
@@ -949,6 +962,8 @@ export function loadGroupFlagAndEnd(
|
||||
foundGroupEnd = true;
|
||||
state._group = { event: lastEventInGroup, isLinkedToLoaded, accumulatedGap };
|
||||
}
|
||||
|
||||
previousWasCountToEnd = entry.countToEnd;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ export { validateEndAction, validateTimerType } from './src/validate-events/vali
|
||||
|
||||
// feature business logic
|
||||
|
||||
export { getExpectedStart } from './src/date-utils/getExpectedStart.js';
|
||||
export { getExpectedStart, getExpectedEnd } from './src/date-utils/getExpected.js';
|
||||
|
||||
// feature business logic - rundown
|
||||
export { checkIsNow } from './src/date-utils/checkIsNow.js';
|
||||
|
||||
+122
-1
@@ -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 './getExpected';
|
||||
|
||||
describe('getExpectedStart()', () => {
|
||||
describe('Absolute offset mode', () => {
|
||||
@@ -315,3 +315,124 @@ 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('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 ignores upstream delays and stays pinned to its fixed end', () => {
|
||||
const testEvent = {
|
||||
timeStart: 100,
|
||||
duration: 50,
|
||||
delay: 20,
|
||||
dayOffset: 0 as Day,
|
||||
};
|
||||
|
||||
// events shift their schedule based on delay...
|
||||
expect(getExpectedEnd({ ...testEvent, countToEnd: false }, { ...baseState, offset: 0 })).toBe(170);
|
||||
// ... but count to end events stay pinned to the scheduled end
|
||||
expect(getExpectedEnd({ ...testEvent, countToEnd: true }, { ...baseState, offset: 0 })).toBe(150);
|
||||
});
|
||||
|
||||
test('a countToEnd event drifts when a delay pushes its start past the fixed end', () => {
|
||||
const testEvent = {
|
||||
timeStart: 100,
|
||||
duration: 50,
|
||||
delay: 60,
|
||||
dayOffset: 0 as Day,
|
||||
countToEnd: true,
|
||||
};
|
||||
|
||||
// the delayed start (160) is past the fixed end (150), so the event can no longer
|
||||
// finish on time and the end follows the compromised start
|
||||
expect(getExpectedEnd(testEvent, { ...baseState, offset: 0 })).toBe(160);
|
||||
});
|
||||
|
||||
test('a countToEnd event on a later day keeps the day offset on the end', () => {
|
||||
const testEvent = {
|
||||
timeStart: 100,
|
||||
duration: 50,
|
||||
delay: 0,
|
||||
dayOffset: 1 as Day,
|
||||
countToEnd: true,
|
||||
};
|
||||
|
||||
// the scheduled end must include the day offset (timeStart + dayInMs + duration),
|
||||
// not collapse to the day-shifted start
|
||||
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);
|
||||
});
|
||||
|
||||
test('a countToEnd event is anchored to its wall-clock end in relative mode', () => {
|
||||
const testEvent = {
|
||||
timeStart: 100,
|
||||
duration: 50,
|
||||
delay: 0,
|
||||
dayOffset: 0 as Day,
|
||||
countToEnd: true,
|
||||
};
|
||||
|
||||
const relativeState = {
|
||||
...baseState,
|
||||
mode: OffsetMode.Relative,
|
||||
actualStart: 30,
|
||||
plannedStart: 0,
|
||||
offset: 0,
|
||||
};
|
||||
|
||||
// a regular event in the same state is shifted by the relative-start offset to 180
|
||||
expect(getExpectedEnd({ ...testEvent, countToEnd: false }, relativeState)).toBe(180);
|
||||
// the countToEnd event stays pinned to its wall-clock end (150), not shifted
|
||||
expect(getExpectedEnd(testEvent, relativeState)).toBe(150);
|
||||
});
|
||||
});
|
||||
+36
-16
@@ -4,25 +4,21 @@ import { OffsetMode } from 'ontime-types';
|
||||
import { dayInMs } from './conversionUtils.js';
|
||||
|
||||
/**
|
||||
* @param event the event that we are counting to
|
||||
* @param currentDay the day offset of the currently running event
|
||||
* @param totalGap accumulated gap from the current event
|
||||
* @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event
|
||||
* @param clock
|
||||
* @param offset
|
||||
* @returns
|
||||
* Runtime context shared by the expected start/end calculations
|
||||
*/
|
||||
type ExpectedTimesState = {
|
||||
currentDay: number; // the current day from the rundown
|
||||
totalGap: number; // accumulated gap from the current event
|
||||
isLinkedToLoaded: boolean; // is this event part of a chain linking back to the loaded event
|
||||
offset: number;
|
||||
mode: OffsetMode;
|
||||
actualStart: MaybeNumber;
|
||||
plannedStart: MaybeNumber;
|
||||
};
|
||||
|
||||
export function getExpectedStart(
|
||||
event: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'>,
|
||||
state: {
|
||||
currentDay: number; // the current day from the rundown
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
offset: number;
|
||||
mode: OffsetMode;
|
||||
actualStart: MaybeNumber;
|
||||
plannedStart: MaybeNumber;
|
||||
},
|
||||
state: ExpectedTimesState,
|
||||
): number {
|
||||
const { timeStart, dayOffset, delay } = event;
|
||||
const { currentDay, totalGap, isLinkedToLoaded, offset, mode, actualStart, plannedStart } = state;
|
||||
@@ -60,3 +56,27 @@ export function getExpectedStart(
|
||||
const offsetStartTimeBufferedByGaps = offsetStartTime - totalGap;
|
||||
return offsetStartTimeBufferedByGaps;
|
||||
}
|
||||
|
||||
export function getExpectedEnd(
|
||||
event: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay' | 'duration' | 'countToEnd'>,
|
||||
state: ExpectedTimesState,
|
||||
): number {
|
||||
// expected start includes the delay and any offset compensation
|
||||
const expectedStart = getExpectedStart(event, state);
|
||||
|
||||
/**
|
||||
* Count to end events are a special case
|
||||
* - the end time is always the wall clock
|
||||
*/
|
||||
if (event.countToEnd) {
|
||||
// account for day offset
|
||||
const relativeDayOffset = event.dayOffset - state.currentDay;
|
||||
const plannedEnd = event.timeStart + event.duration + relativeDayOffset * dayInMs;
|
||||
|
||||
// count to end should finish on the planned time or on start
|
||||
return Math.max(expectedStart, plannedEnd);
|
||||
}
|
||||
|
||||
// for normal events, the expected end is when we would start + its duration
|
||||
return expectedStart + event.duration;
|
||||
}
|
||||
Reference in New Issue
Block a user