Refactor: split runtime state (#1725)

* refactor: runtime types

* refactor: runtimeState functions to use the split type

* refactor: RuntimeService to use the new split data

* refactor: use the split data in the UI

* update comments

* refactor: rename runtime to offset

* fix utils test
This commit is contained in:
Alex Christoffer Rasmussen
2025-08-14 19:29:25 +02:00
committed by Carlos Valente
parent ed93cf313a
commit 048a9c96ea
26 changed files with 400 additions and 518 deletions
+24 -23
View File
@@ -58,8 +58,8 @@ export const setMessage = {
export const usePlaybackControl = createSelector((state: RuntimeStore) => ({ export const usePlaybackControl = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback, playback: state.timer.playback,
selectedEventIndex: state.runtime.selectedEventIndex, selectedEventIndex: state.rundown.selectedEventIndex,
numEvents: state.runtime.numEvents, numEvents: state.rundown.numEvents,
timerPhase: state.timer.phase, timerPhase: state.timer.phase,
})); }));
@@ -150,8 +150,8 @@ export const useClock = createSelector((state: RuntimeStore) => ({
})); }));
export const useNextFlag = createSelector((state: RuntimeStore) => ({ export const useNextFlag = createSelector((state: RuntimeStore) => ({
id: state.nextFlag?.id ?? null, id: state.eventFlag?.id ?? null,
expectedStart: state.nextFlag?.expectedStart ?? null, expectedStart: state.offset.expectedFlagStart,
})); }));
/** Used by the progress bar components */ /** Used by the progress bar components */
@@ -162,35 +162,35 @@ export const useProgressData = createSelector((state: RuntimeStore) => ({
timeDanger: state.eventNow?.timeDanger ?? null, timeDanger: state.eventNow?.timeDanger ?? null,
})); }));
export const useRuntimeOverview = createSelector((state: RuntimeStore) => ({ export const useRundownOverview = createSelector((state: RuntimeStore) => ({
plannedStart: state.runtime.plannedStart, plannedStart: state.rundown.plannedStart,
actualStart: state.runtime.actualStart, actualStart: state.rundown.actualStart,
plannedEnd: state.runtime.plannedEnd, plannedEnd: state.rundown.plannedEnd,
expectedEnd: state.runtime.expectedEnd, expectedEnd: state.offset.expectedRundownEnd,
})); }));
export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) => ({ export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback, playback: state.timer.playback,
clock: state.clock, clock: state.clock,
numEvents: state.runtime.numEvents, numEvents: state.rundown.numEvents,
selectedEventIndex: state.runtime.selectedEventIndex, selectedEventIndex: state.rundown.selectedEventIndex,
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offsetAbs : state.runtime.offsetRel, offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
groupExpectedEnd: state.groupNow?.expectedEnd ?? null, groupExpectedEnd: state.offset.expectedGroupEnd,
})); }));
export const useTimelineStatus = createSelector((state: RuntimeStore) => ({ export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
clock: state.clock, clock: state.clock,
offset: state.runtime.offsetAbs, offset: state.offset.absolute,
})); }));
export const useExpectedStartData = createSelector((state: RuntimeStore) => ({ export const useExpectedStartData = createSelector((state: RuntimeStore) => ({
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offsetAbs : state.runtime.offsetRel, offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
offsetMode: state.runtime.offsetMode, mode: state.offset.mode,
currentDay: state.eventNow?.dayOffset ?? 0, currentDay: state.eventNow?.dayOffset ?? 0,
actualStart: state.runtime.actualStart, actualStart: state.rundown.actualStart,
plannedStart: state.runtime.plannedStart, plannedStart: state.rundown.plannedStart,
clock: state.clock, clock: state.clock,
})); }));
@@ -199,7 +199,7 @@ export const useCurrentDay = createSelector((state: RuntimeStore) => ({
})); }));
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({ export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
offset: state.runtime.offsetAbs, offset: state.offset.absolute,
})); }));
export const usePing = createSelector((state: RuntimeStore) => ({ export const usePing = createSelector((state: RuntimeStore) => ({
@@ -212,7 +212,7 @@ export const useIsOnline = createSelector((state: RuntimeStore) => ({
})); }));
export const useOffsetMode = createSelector((state: RuntimeStore) => ({ export const useOffsetMode = createSelector((state: RuntimeStore) => ({
offsetMode: state.runtime.offsetMode, offsetMode: state.offset.mode,
})); }));
export const setOffsetMode = (payload: OffsetMode) => sendSocket('offsetmode', payload); export const setOffsetMode = (payload: OffsetMode) => sendSocket('offsetmode', payload);
@@ -251,7 +251,7 @@ export const useCountdownSocket = createSelector((state: RuntimeStore) => ({
export const useBackstageSocket = createSelector((state: RuntimeStore) => ({ export const useBackstageSocket = createSelector((state: RuntimeStore) => ({
eventNext: state.eventNext, eventNext: state.eventNext,
eventNow: state.eventNow, eventNow: state.eventNow,
runtime: state.runtime, rundown: state.rundown,
selectedEventId: state.eventNow?.id ?? null, selectedEventId: state.eventNow?.id ?? null,
time: state.timer, time: state.timer,
})); }));
@@ -266,10 +266,11 @@ export const useStudioTimersSocket = createSelector((state: RuntimeStore) => ({
eventNow: state.eventNow, eventNow: state.eventNow,
message: state.message, message: state.message,
time: state.timer, time: state.timer,
runtime: state.runtime, offset: state.offset,
rundown: state.rundown,
})); }));
export const useTimelineSocket = createSelector((state: RuntimeStore) => ({ export const useTimelineSocket = createSelector((state: RuntimeStore) => ({
clock: state.clock, clock: state.clock,
offsetAbs: state.runtime.offsetAbs, offsetAbs: state.offset.absolute,
})); }));
+2 -2
View File
@@ -145,12 +145,12 @@ export function useTimeUntilExpectedStart(
isLinkedToLoaded: boolean; isLinkedToLoaded: boolean;
}, },
): number { ): number {
const { offset, currentDay, offsetMode, actualStart, plannedStart, clock } = useExpectedStartData(); const { offset, currentDay, mode, actualStart, plannedStart, clock } = useExpectedStartData();
if (event === null) return 0; if (event === null) return 0;
const expectedStart = getExpectedStart( const expectedStart = getExpectedStart(
{ ...event }, { ...event },
{ ...state, currentDay, offset, offsetMode, actualStart, plannedStart }, { ...state, currentDay, offset, mode, actualStart, plannedStart },
); );
return expectedStart - clock; return expectedStart - clock;
} }
@@ -18,15 +18,16 @@ const staticAutocompleteOptions = [
'{{timer.expectedFinish}}', '{{timer.expectedFinish}}',
'{{timer.secondaryTimer}}', '{{timer.secondaryTimer}}',
'{{timer.startedAt}}', '{{timer.startedAt}}',
'{{runtime.selectedEventIndex}}', '{{rundown.selectedEventIndex}}',
'{{runtime.numEvents}}', '{{rundown.numEvents}}',
'{{runtime.offset}}', '{{rundown.plannedStart}}',
'{{runtime.plannedStart}}', '{{rundown.plannedEnd}}',
'{{runtime.plannedEnd}}', '{{rundown.actualStart}}',
'{{runtime.actualStart}}', '{{offset.absolute}}',
'{{runtime.expectedEnd}}', '{{offset.relative}}',
'{{currentGroup.id}}', '{{offset.expectedRundownEnd}}',
'{{currentGroup.startedAt}}', '{{offset.expectedGroupEnd}}',
'{{offset.expectedFlagStart}}',
]; ];
const eventStaticPropertiesNow = [ const eventStaticPropertiesNow = [
@@ -16,7 +16,7 @@ import {
useClock, useClock,
useCurrentGroupId, useCurrentGroupId,
useNextFlag, useNextFlag,
useRuntimeOverview, useRundownOverview,
useRuntimePlaybackOverview, useRuntimePlaybackOverview,
useTimer, useTimer,
} from '../../../common/hooks/useSocket'; } from '../../../common/hooks/useSocket';
@@ -31,7 +31,7 @@ import { OverUnder, TimeColumn } from './TimeLayout';
import style from './TimeElements.module.scss'; import style from './TimeElements.module.scss';
export function StartTimes() { export function StartTimes() {
const { plannedEnd, plannedStart, actualStart, expectedEnd } = useRuntimeOverview(); const { plannedEnd, plannedStart, actualStart, expectedEnd } = useRundownOverview();
const plannedStartText = plannedStart === null ? timerPlaceholder : formatTime(plannedStart); const plannedStartText = plannedStart === null ? timerPlaceholder : formatTime(plannedStart);
@@ -44,7 +44,7 @@ export default function BackstageLoader() {
function Backstage({ events, customFields, projectData, isMirrored, settings }: BackstageData) { function Backstage({ events, customFields, projectData, isMirrored, settings }: BackstageData) {
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const { secondarySource, extraInfo } = useBackstageOptions(); const { secondarySource, extraInfo } = useBackstageOptions();
const { eventNext, eventNow, runtime, selectedEventId, time } = useBackstageSocket(); const { eventNext, eventNow, rundown, selectedEventId, time } = useBackstageSocket();
const [blinkClass, setBlinkClass] = useState(false); const [blinkClass, setBlinkClass] = useState(false);
const { height: screenHeight } = useViewportSize(); const { height: screenHeight } = useViewportSize();
@@ -76,13 +76,13 @@ function Backstage({ events, customFields, projectData, isMirrored, settings }:
const scheduledStart = (() => { const scheduledStart = (() => {
if (showNow) return undefined; if (showNow) return undefined;
if (!hasEvents) return undefined; if (!hasEvents) return undefined;
return formatTime(runtime.plannedStart, { format12: 'hh:mm a', format24: 'HH:mm' }); return formatTime(rundown.plannedStart, { format12: 'hh:mm a', format24: 'HH:mm' });
})(); })();
const scheduledEnd = (() => { const scheduledEnd = (() => {
if (showNow) return undefined; if (showNow) return undefined;
if (!hasEvents) return undefined; if (!hasEvents) return undefined;
return formatTime(runtime.plannedEnd, { format12: 'hh:mm a', format24: 'HH:mm' }); return formatTime(rundown.plannedEnd, { format12: 'hh:mm a', format24: 'HH:mm' });
})(); })();
let displayTimer = millisToString(time.current, { fallback: timerPlaceholderMin }); let displayTimer = millisToString(time.current, { fallback: timerPlaceholderMin });
@@ -17,9 +17,13 @@ interface StudioTimersProps {
export default function StudioTimers({ viewSettings }: StudioTimersProps) { export default function StudioTimers({ viewSettings }: StudioTimersProps) {
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const { eventNow, eventNext, message, time, runtime } = useStudioTimersSocket(); const { eventNow, eventNext, message, time, offset, rundown } = useStudioTimersSocket();
const schedule = getFormattedScheduleTimes(runtime); const schedule = getFormattedScheduleTimes({
offset: offset.absolute,
actualStart: rundown.actualStart,
expectedEnd: offset.expectedRundownEnd,
});
const event = getFormattedEventData(eventNow, time); const event = getFormattedEventData(eventNow, time);
const eventNextTitle = eventNext?.title || '-'; const eventNextTitle = eventNext?.title || '-';
const formattedTimerMessage = (message.timer.visible && message.timer.text) || '-'; const formattedTimerMessage = (message.timer.visible && message.timer.text) || '-';
@@ -33,7 +37,7 @@ export default function StudioTimers({ viewSettings }: StudioTimersProps) {
time.phase === TimerPhase.Danger, time.phase === TimerPhase.Danger,
); );
const offsetState = getOffsetState(runtime.offsetAbs); const offsetState = getOffsetState(offset.absolute);
return ( return (
<div className='studio__timers'> <div className='studio__timers'>
@@ -1,16 +1,19 @@
import { OntimeEvent, Runtime, TimerState } from 'ontime-types'; import { MaybeNumber, OntimeEvent, TimerState } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import { getOffsetText } from '../../common/utils/offset'; import { getOffsetText } from '../../common/utils/offset';
import { formatTime } from '../../common/utils/time'; import { formatTime } from '../../common/utils/time';
const timeFormat = { format12: 'h:mm a', format24: 'HH:mm' }; const timeFormat = { format12: 'h:mm a', format24: 'HH:mm' };
export function getFormattedScheduleTimes(runtime: Runtime) { export function getFormattedScheduleTimes(data: {
const correctedOffset = runtime.offsetAbs !== null ? runtime.offsetAbs * -1 : null; offset: number;
actualStart: MaybeNumber;
expectedEnd: MaybeNumber;
}) {
return { return {
actualStart: formatTime(runtime.actualStart, timeFormat), actualStart: formatTime(data.actualStart, timeFormat),
expectedEnd: formatTime(runtime.expectedEnd, timeFormat), expectedEnd: formatTime(data.expectedEnd, timeFormat),
offset: getOffsetText(correctedOffset), offset: getOffsetText(data.offset),
}; };
} }
+3 -3
View File
@@ -184,12 +184,12 @@ export const startServer = async (): Promise<{ message: string; serverPort: numb
clock: state.clock, clock: state.clock,
timer: state.timer, timer: state.timer,
message: { ...runtimeStorePlaceholder.message }, message: { ...runtimeStorePlaceholder.message },
runtime: state.runtime, offset: state.offset,
rundown: state.rundown,
eventNow: state.eventNow, eventNow: state.eventNow,
eventNext: state.eventNext, eventNext: state.eventNext,
eventFlag: null,
groupNow: null, groupNow: null,
groupNext: null,
nextFlag: null,
auxtimer1: { auxtimer1: {
duration: timerConfig.auxTimerDefault, duration: timerConfig.auxTimerDefault,
current: timerConfig.auxTimerDefault, current: timerConfig.auxTimerDefault,
@@ -12,7 +12,6 @@ export type RestorePoint = {
addedTime: number; addedTime: number;
pausedAt: MaybeNumber; pausedAt: MaybeNumber;
firstStart: MaybeNumber; firstStart: MaybeNumber;
groupStartAt: MaybeNumber;
}; };
/** /**
@@ -51,10 +50,6 @@ export function isRestorePoint(obj: unknown): obj is RestorePoint {
return false; return false;
} }
if (typeof restorePoint.groupStartAt !== 'number' && restorePoint.groupStartAt !== null) {
return false;
}
return true; return true;
} }
@@ -14,7 +14,6 @@ describe('isRestorePoint()', () => {
addedTime: 2, addedTime: 2,
pausedAt: 3, pausedAt: 3,
firstStart: 1, firstStart: 1,
groupStartAt: 10,
}; };
expect(isRestorePoint(restorePoint)).toBe(true); expect(isRestorePoint(restorePoint)).toBe(true);
@@ -25,7 +24,6 @@ describe('isRestorePoint()', () => {
addedTime: 0, addedTime: 0,
pausedAt: null, pausedAt: null,
firstStart: 1, firstStart: 1,
groupStartAt: null,
}; };
expect(isRestorePoint(restorePoint)).toBe(true); expect(isRestorePoint(restorePoint)).toBe(true);
}); });
@@ -76,7 +74,6 @@ describe('RestoreService()', () => {
addedTime: 5678, addedTime: 5678,
pausedAt: 9087, pausedAt: 9087,
firstStart: 1234, firstStart: 1234,
groupStartAt: 1652,
}; };
const restoreService = new RestoreService('/path/to/restore/file'); const restoreService = new RestoreService('/path/to/restore/file');
@@ -94,7 +91,6 @@ describe('RestoreService()', () => {
addedTime: 0, addedTime: 0,
pausedAt: null, pausedAt: null,
firstStart: 1234, firstStart: 1234,
groupStartAt: null,
}; };
const restoreService = new RestoreService('/path/to/restore/file'); const restoreService = new RestoreService('/path/to/restore/file');
@@ -132,7 +128,6 @@ describe('RestoreService()', () => {
addedTime: 1234, addedTime: 1234,
pausedAt: 1234, pausedAt: 1234,
firstStart: 1234, firstStart: 1234,
groupStartAt: null,
}; };
const restoreService = new RestoreService('/path/to/restore/file'); const restoreService = new RestoreService('/path/to/restore/file');
@@ -204,7 +204,7 @@ describe('getExpectedFinish()', () => {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
runtime: { rundown: {
actualStart: 79200000, actualStart: 79200000,
plannedEnd: 600000, plannedEnd: 600000,
}, },
@@ -358,7 +358,7 @@ describe('getCurrent()', () => {
duration: 100, duration: 100,
startedAt: null, startedAt: null,
}, },
runtime: { rundown: {
plannedEnd: null, plannedEnd: null,
}, },
_timer: { _timer: {
@@ -383,7 +383,7 @@ describe('getCurrent()', () => {
duration: 100, duration: 100,
startedAt: 10, startedAt: 10,
}, },
runtime: { rundown: {
plannedEnd: 100, plannedEnd: 100,
}, },
_timer: { _timer: {
@@ -408,7 +408,7 @@ describe('getCurrent()', () => {
duration: 100, duration: 100,
startedAt: 10, startedAt: 10,
}, },
runtime: { rundown: {
plannedEnd: 100, plannedEnd: 100,
}, },
_timer: { _timer: {
@@ -434,7 +434,7 @@ describe('getCurrent()', () => {
duration: Infinity, // not relevant, duration: Infinity, // not relevant,
startedAt: 79200000, // 22:00:00 startedAt: 79200000, // 22:00:00
}, },
runtime: { rundown: {
actualStart: 79200000, actualStart: 79200000,
plannedEnd: 600000, plannedEnd: 600000,
}, },
@@ -462,7 +462,7 @@ describe('getCurrent()', () => {
duration: Infinity, // not relevant, duration: Infinity, // not relevant,
startedAt: 79200000, // 22:00:00 startedAt: 79200000, // 22:00:00
}, },
runtime: { rundown: {
actualStart: 82000000, // 22:46:40 <--- started now actualStart: 82000000, // 22:46:40 <--- started now
plannedEnd: 81000000, // 22:30:00 plannedEnd: 81000000, // 22:30:00
}, },
@@ -735,14 +735,14 @@ describe('getRuntimeOffset()', () => {
_timer: { _timer: {
pausedAt: null, pausedAt: null,
}, },
runtime: { rundown: {
actualStart: 150, actualStart: 150,
plannedStart: 100, plannedStart: 100,
}, },
} as RuntimeState; } as RuntimeState;
const { offsetAbs } = getRuntimeOffset(state); const { absolute } = getRuntimeOffset(state);
expect(offsetAbs).toBe(50); expect(absolute).toBe(50);
}); });
it('added time subtracts time offset (positive offset)', () => { it('added time subtracts time offset (positive offset)', () => {
@@ -759,14 +759,14 @@ describe('getRuntimeOffset()', () => {
_timer: { _timer: {
pausedAt: null, pausedAt: null,
}, },
runtime: { rundown: {
actualStart: 150, actualStart: 150,
plannedStart: 100, plannedStart: 100,
}, },
} as RuntimeState; } as RuntimeState;
const { offsetAbs } = getRuntimeOffset(state); const { absolute } = getRuntimeOffset(state);
expect(offsetAbs).toBe(60); expect(absolute).toBe(60);
}); });
it('considers running overtime (negative offset)', () => { it('considers running overtime (negative offset)', () => {
@@ -784,14 +784,14 @@ describe('getRuntimeOffset()', () => {
_timer: { _timer: {
pausedAt: null, pausedAt: null,
}, },
runtime: { rundown: {
actualStart: 100, actualStart: 100,
plannedStart: 100, plannedStart: 100,
}, },
} as RuntimeState; } as RuntimeState;
const { offsetAbs } = getRuntimeOffset(state); const { absolute } = getRuntimeOffset(state);
expect(offsetAbs).toBe(10); expect(absolute).toBe(10);
}); });
it('paused time is delayed time (negative offset)', () => { it('paused time is delayed time (negative offset)', () => {
@@ -810,14 +810,14 @@ describe('getRuntimeOffset()', () => {
_timer: { _timer: {
pausedAt: 125, // we have been paused for 25ms (see clock) pausedAt: 125, // we have been paused for 25ms (see clock)
}, },
runtime: { rundown: {
actualStart: 100, actualStart: 100,
plannedStart: 100, plannedStart: 100,
}, },
} as RuntimeState; } as RuntimeState;
const { offsetAbs } = getRuntimeOffset(state); const { absolute } = getRuntimeOffset(state);
expect(offsetAbs).toBe(25); expect(absolute).toBe(25);
}); });
it('offset doesnt exist if we havent started', () => { it('offset doesnt exist if we havent started', () => {
@@ -831,14 +831,16 @@ describe('getRuntimeOffset()', () => {
timeStrategy: 'lock-duration', timeStrategy: 'lock-duration',
linkStart: false, linkStart: false,
}, },
runtime: { rundown: {
selectedEventIndex: 0, selectedEventIndex: 0,
numEvents: 2, numEvents: 2,
offsetAbs: -77400000,
plannedStart: 77400000, plannedStart: 77400000,
plannedEnd: 84600000, plannedEnd: 84600000,
actualStart: null, actualStart: null,
expectedEnd: null, },
offset: {
absolute: -77400000,
expectedRundownEnd: null,
}, },
timer: { timer: {
addedTime: 0, addedTime: 0,
@@ -853,8 +855,8 @@ describe('getRuntimeOffset()', () => {
_timer: { pausedAt: null }, _timer: { pausedAt: null },
} as RuntimeState; } as RuntimeState;
const { offsetAbs } = getRuntimeOffset(state); const { absolute } = getRuntimeOffset(state);
expect(offsetAbs).toBe(0); expect(absolute).toBe(0);
}); });
it('with time-to-end, offsets dont exist if we are not in overtime', () => { it('with time-to-end, offsets dont exist if we are not in overtime', () => {
@@ -882,14 +884,16 @@ describe('getRuntimeOffset()', () => {
custom: {}, custom: {},
delay: 0, delay: 0,
}, },
runtime: { rundown: {
selectedEventIndex: 0, selectedEventIndex: 0,
numEvents: 1, numEvents: 1,
offsetAbs: 0,
plannedStart: 77400000, // 21:30:00 plannedStart: 77400000, // 21:30:00
plannedEnd: 81000000, // 22:30:00 plannedEnd: 81000000, // 22:30:00
actualStart: 78000000, // 21:40:00 actualStart: 78000000, // 21:40:00
expectedEnd: 81600000, // 22:40:00 },
offset: {
absolute: 0,
expectedRundownEnd: 81600000, // 22:40:00
}, },
timer: { timer: {
addedTime: 0, addedTime: 0,
@@ -904,8 +908,8 @@ describe('getRuntimeOffset()', () => {
_timer: { pausedAt: null }, _timer: { pausedAt: null },
} as RuntimeState; } as RuntimeState;
const { offsetAbs } = getRuntimeOffset(state); const { absolute } = getRuntimeOffset(state);
expect(offsetAbs).toBe(0); expect(absolute).toBe(0);
}); });
it('with time-to-end, offset is the overtime', () => { it('with time-to-end, offset is the overtime', () => {
@@ -933,14 +937,16 @@ describe('getRuntimeOffset()', () => {
custom: {}, custom: {},
delay: 0, delay: 0,
}, },
runtime: { rundown: {
selectedEventIndex: 0, selectedEventIndex: 0,
numEvents: 1, numEvents: 1,
offsetAbs: 0,
plannedStart: 77400000, // 21:30:00 plannedStart: 77400000, // 21:30:00
plannedEnd: 81000000, // 22:30:00 plannedEnd: 81000000, // 22:30:00
actualStart: 78000000, // 21:40:00 actualStart: 78000000, // 21:40:00
expectedEnd: 81600000, // 22:40:00 },
offset: {
absolute: 0,
expectedRundownEnd: 81600000, // 22:40:00
}, },
timer: { timer: {
addedTime: -200000, addedTime: -200000,
@@ -955,8 +961,8 @@ describe('getRuntimeOffset()', () => {
_timer: { pausedAt: null }, _timer: { pausedAt: null },
} as RuntimeState; } as RuntimeState;
const { offsetAbs } = getRuntimeOffset(state); const { absolute } = getRuntimeOffset(state);
expect(offsetAbs).toBe(400000); // <--- offset is always the overtime expect(absolute).toBe(400000); // <--- offset is always the overtime
}); });
it('handles time-to-end started after the end time', () => { it('handles time-to-end started after the end time', () => {
@@ -973,14 +979,16 @@ describe('getRuntimeOffset()', () => {
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
countToEnd: true, countToEnd: true,
}, },
runtime: { rundown: {
selectedEventIndex: 0, selectedEventIndex: 0,
numEvents: 1, numEvents: 1,
offsetAbs: 0,
plannedStart: 77400000, // 21:30:00 plannedStart: 77400000, // 21:30:00
plannedEnd: 81000000, // 22:30:00 plannedEnd: 81000000, // 22:30:00
actualStart: 82000000, // 22:46:40 <--- started now actualStart: 82000000, // 22:46:40 <--- started now
expectedEnd: 82000000 + 3600000, // <--- now + duration },
offset: {
absolute: 0,
expectedRundownEnd: 82000000 + 3600000, // <--- now + duration
}, },
timer: { timer: {
addedTime: 0, addedTime: 0,
@@ -997,9 +1005,9 @@ describe('getRuntimeOffset()', () => {
const updateCurrent = getCurrent(state); const updateCurrent = getCurrent(state);
state.timer.current = updateCurrent; state.timer.current = updateCurrent;
const { offsetAbs } = getRuntimeOffset(state); const { absolute } = getRuntimeOffset(state);
expect(millisToString(offsetAbs)).toBe('00:16:40'); expect(millisToString(absolute)).toBe('00:16:40');
expect(offsetAbs).toBe(82000000 - 81000000); // <-- now - planned end expect(absolute).toBe(82000000 - 81000000); // <-- now - planned end
}); });
}); });
@@ -1018,15 +1026,15 @@ describe('getoffsetRel()', () => {
_timer: { _timer: {
pausedAt: null, pausedAt: null,
}, },
runtime: { rundown: {
actualStart: 150, actualStart: 150,
plannedStart: 150, plannedStart: 150,
}, },
} as RuntimeState; } as RuntimeState;
const { offsetAbs, offsetRel } = getRuntimeOffset(state); const { absolute, relative } = getRuntimeOffset(state);
expect(offsetAbs).toBe(0); expect(absolute).toBe(0);
expect(offsetRel).toBe(0); expect(relative).toBe(0);
}); });
it('relative offset is 0 when starting after the planed time', () => { it('relative offset is 0 when starting after the planed time', () => {
const state = { const state = {
@@ -1042,15 +1050,15 @@ describe('getoffsetRel()', () => {
_timer: { _timer: {
pausedAt: null, pausedAt: null,
}, },
runtime: { rundown: {
actualStart: 150, actualStart: 150,
plannedStart: 100, plannedStart: 100,
}, },
} as RuntimeState; } as RuntimeState;
const { offsetAbs, offsetRel } = getRuntimeOffset(state); const { absolute, relative } = getRuntimeOffset(state);
expect(offsetAbs).toBe(50); expect(absolute).toBe(50);
expect(offsetRel).toBe(0); expect(relative).toBe(0);
}); });
it('relative offset is 0 when starting before the planed time', () => { it('relative offset is 0 when starting before the planed time', () => {
const state = { const state = {
@@ -1066,15 +1074,15 @@ describe('getoffsetRel()', () => {
_timer: { _timer: {
pausedAt: null, pausedAt: null,
}, },
runtime: { rundown: {
actualStart: 100, actualStart: 100,
plannedStart: 150, plannedStart: 150,
}, },
} as RuntimeState; } as RuntimeState;
const { offsetAbs, offsetRel } = getRuntimeOffset(state); const { absolute, relative } = getRuntimeOffset(state);
expect(offsetAbs).toBe(-50); expect(absolute).toBe(-50);
expect(offsetRel).toBe(0); expect(relative).toBe(0);
}); });
}); });
@@ -1175,14 +1183,16 @@ describe('getTimerPhase()', () => {
clock: 55691050, clock: 55691050,
eventNow: null, eventNow: null,
eventNext: null, eventNext: null,
runtime: { rundown: {
selectedEventIndex: null, selectedEventIndex: null,
numEvents: 1, numEvents: 1,
offsetAbs: 0,
plannedStart: 55860000, plannedStart: 55860000,
plannedEnd: 55880000, plannedEnd: 55880000,
actualStart: null, actualStart: null,
expectedEnd: null, },
offset: {
absolute: 0,
expectedRundownEnd: null,
}, },
timer: { timer: {
addedTime: 0, addedTime: 0,
@@ -1213,14 +1223,16 @@ describe('getTimerPhase()', () => {
clock: 55691050, clock: 55691050,
eventNow: null, eventNow: null,
eventNext: null, eventNext: null,
runtime: { rundown: {
selectedEventIndex: null, selectedEventIndex: null,
numEvents: 1, numEvents: 1,
offsetAbs: 0,
plannedStart: 55860000, plannedStart: 55860000,
plannedEnd: 55880000, plannedEnd: 55880000,
actualStart: null, actualStart: null,
expectedEnd: null, },
offset: {
absolute: 0,
expectedRundownEnd: null,
}, },
timer: { timer: {
addedTime: 0, addedTime: 0,
@@ -3,10 +3,10 @@ import {
isOntimeEvent, isOntimeEvent,
isPlayableEvent, isPlayableEvent,
LogOrigin, LogOrigin,
MaybeNumber,
OffsetMode, OffsetMode,
OntimeEvent, OntimeEvent,
Playback, Playback,
RuntimeStore,
TimerLifeCycle, TimerLifeCycle,
TimerPhase, TimerPhase,
TimerState, TimerState,
@@ -34,16 +34,12 @@ import {
findPreviousPlayableId, findPreviousPlayableId,
getEventAtIndex, getEventAtIndex,
getShouldClockUpdate, getShouldClockUpdate,
getShouldFlagUpdate, getShouldOffsetUpdate,
getShouldGroupUpdate,
getShouldRuntimeUpdate,
getShouldTimerUpdate, getShouldTimerUpdate,
isNewSecond, isNewSecond,
} from './rundownService.utils.js'; } from './rundownService.utils.js';
import { RundownMetadata } from '../../api-data/rundown/rundown.types.js'; import { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
type RuntimeStateEventKeys = keyof Pick<RuntimeState, 'eventNext' | 'eventNow'>;
/** /**
* Service manages runtime status of app * Service manages runtime status of app
* Coordinating with necessary services * Coordinating with necessary services
@@ -53,22 +49,11 @@ class RuntimeService {
private lastIntegrationClockUpdate = -1; private lastIntegrationClockUpdate = -1;
private lastIntegrationTimerValue = -1; private lastIntegrationTimerValue = -1;
/** last time we updated the socket */
static previousTimerUpdate: number;
static previousRuntimeUpdate: number;
static previousTimerValue: MaybeNumber; // previous timer value, could be null
static previousClockUpdate: number;
/** last known state */ /** last known state */
static previousState: RuntimeState; static previousState: RuntimeState;
constructor(eventTimer: EventTimer) { constructor(eventTimer: EventTimer) {
this.eventTimer = eventTimer; this.eventTimer = eventTimer;
RuntimeService.previousTimerUpdate = -1;
RuntimeService.previousRuntimeUpdate = -1;
RuntimeService.previousTimerValue = -1;
RuntimeService.previousClockUpdate = -1;
RuntimeService.previousState = {} as RuntimeState; RuntimeService.previousState = {} as RuntimeState;
} }
@@ -101,7 +86,7 @@ class RuntimeService {
// 2. handle edge cases related to roll // 2. handle edge cases related to roll
if (newState.timer.playback === Playback.Roll) { if (newState.timer.playback === Playback.Roll) {
// check if we need to call any side effects // check if we need to call any side effects
const keepOffset = newState.runtime.offsetAbs; const keepOffset = newState.offset.absolute;
if (hasSecondaryTimerFinished) { if (hasSecondaryTimerFinished) {
// if the secondary timer has finished, we need to call roll // if the secondary timer has finished, we need to call roll
// since event is already loaded // since event is already loaded
@@ -292,7 +277,7 @@ class RuntimeService {
rundown, rundown,
playableEventOrder, playableEventOrder,
cue, cue,
state.runtime.selectedEventIndex ?? undefined, state.rundown.selectedEventIndex ?? undefined,
); );
if (!event) { if (!event) {
@@ -353,7 +338,7 @@ class RuntimeService {
rundown, rundown,
playableEventOrder, playableEventOrder,
cue, cue,
state.runtime.selectedEventIndex ?? undefined, state.rundown.selectedEventIndex ?? undefined,
); );
if (!event) { if (!event) {
@@ -412,7 +397,7 @@ class RuntimeService {
} }
if (state.timer.playback === Playback.Roll) { if (state.timer.playback === Playback.Roll) {
return this.loadEvent(nextEvent, { firstStart: state.runtime.actualStart }); return this.loadEvent(nextEvent, { firstStart: state.rundown.actualStart });
} }
return this.loadEvent(nextEvent); return this.loadEvent(nextEvent);
} }
@@ -667,20 +652,24 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
// we do the comparison by explicitly for each property // we do the comparison by explicitly for each property
// to apply custom logic for different datasets // to apply custom logic for different datasets
// if a new event was loaded most things should update // Update the entry if they have changed
const hasNewLoaded = state.eventNow?.id !== RuntimeService.previousState?.eventNow?.id; let entryChanged = false;
entryChanged ||= updateMaybeEntryIfChanged('eventNow');
entryChanged ||= updateMaybeEntryIfChanged('eventNext');
entryChanged ||= updateMaybeEntryIfChanged('eventFlag');
entryChanged ||= updateMaybeEntryIfChanged('groupNow');
// for the very fist run there will be nothing in the previousState so we force an update // for the very fist run there will be nothing in the previousState so we force an update
const justStarted = !RuntimeService.previousState?.timer; const justStarted = !RuntimeService.previousState?.timer;
// offset mode has been changed // offset mode has been changed
const offsetModeChanged = RuntimeService.previousState?.runtime?.offsetMode !== state.runtime.offsetMode; const offsetModeChanged = RuntimeService.previousState?.offset?.mode !== state.offset.mode;
// if playback changes most things should update // if playback changes most things should update
const hasChangedPlayback = RuntimeService.previousState.timer?.playback !== state.timer.playback; const hasChangedPlayback = RuntimeService.previousState.timer?.playback !== state.timer.playback;
// combine all big changes // combine all big changes
const hasImmediateChanges = hasNewLoaded || justStarted || hasChangedPlayback || offsetModeChanged; const hasImmediateChanges = entryChanged || justStarted || hasChangedPlayback || offsetModeChanged;
// clock has changed by a second or more // clock has changed by a second or more
const updateClock = getShouldClockUpdate(RuntimeService.previousState.clock, state.clock); const updateClock = getShouldClockUpdate(RuntimeService.previousState.clock, state.clock);
@@ -693,74 +682,43 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
const updateTimer = getShouldTimerUpdate(RuntimeService.previousState?.timer, state.timer); const updateTimer = getShouldTimerUpdate(RuntimeService.previousState?.timer, state.timer);
if (updateTimer) { if (updateTimer) {
batch.add('timer', state.timer); batch.add('timer', state.timer);
RuntimeService.previousTimerUpdate = state.clock;
RuntimeService.previousTimerValue = state.timer.current;
RuntimeService.previousState.timer = { ...state.timer }; RuntimeService.previousState.timer = { ...state.timer };
} }
// if any values have changed, values that have the possibility to tick are modulated by `hasClockUpdate` // if any values have changed, values that have the possibility to tick are modulated by `hasClockUpdate`
const updateRuntime = getShouldRuntimeUpdate( const updateRuntime = getShouldOffsetUpdate(
RuntimeService.previousState?.runtime, RuntimeService.previousState?.offset,
state.runtime, state.offset,
updateClock || hasImmediateChanges, updateClock || hasImmediateChanges,
); );
if (updateRuntime) { if (updateRuntime) {
batch.add('runtime', state.runtime); batch.add('offset', state.offset);
RuntimeService.previousRuntimeUpdate = state.clock; RuntimeService.previousState.offset = structuredClone(state.offset);
RuntimeService.previousState.runtime = structuredClone(state.runtime);
} }
// if any values have changed, values that have the possibility to tick are modulated by `hasClockUpdate` // if any values have changed
const updateGroupNow = getShouldGroupUpdate( const updateRundownData = !deepEqual(RuntimeService.previousState.rundown, state.rundown);
RuntimeService.previousState.groupNow, if (updateRundownData) {
state.groupNow, batch.add('rundown', state.rundown);
updateClock || hasImmediateChanges, RuntimeService.previousState.rundown = structuredClone(state.rundown);
);
if (updateGroupNow) {
batch.add('groupNow', state.groupNow);
RuntimeService.previousState.groupNow = structuredClone(state.groupNow);
} }
// next group is just a simple string or null compare function updateMaybeEntryIfChanged<
const updateGroupNext = RuntimeService.previousState.groupNext !== state.groupNext; K extends keyof Pick<RuntimeState, 'eventNow' | 'eventNext' | 'eventFlag' | 'groupNow'>,
if (updateGroupNext) { >(key: K) {
batch.add('groupNext', state.groupNext); const previousEntry = RuntimeService.previousState[key];
RuntimeService.previousState.groupNext = structuredClone(state.groupNext); const currentEntry = state[key];
}
// if any values have changed, values that have the possibility to tick are modulated by `hasClockUpdate` if (!previousEntry && !currentEntry) return false; // if both are null -> skip
const updateFlag = getShouldFlagUpdate(
RuntimeService.previousState.nextFlag,
state.nextFlag,
updateClock || hasImmediateChanges,
);
if (updateFlag) {
batch.add('nextFlag', state.nextFlag);
RuntimeService.previousState.nextFlag = structuredClone(state.nextFlag);
}
// Update the events if they have changed // if they have the same id the check if the contents have changed
updateEventIfChanged('eventNow', state); if (previousEntry?.id === currentEntry?.id) {
updateEventIfChanged('eventNext', state); if (deepEqual(previousEntry, currentEntry)) return false; // contents are the same -> skip
}
// Helper function to update an event if it has changed // at this point we know that either the id or the contents has changed
function updateEventIfChanged(eventKey: RuntimeStateEventKeys, state: runtimeState.RuntimeState) { batch.add(key, currentEntry as RuntimeStore[K]); // we know that there is the necessary overlap in the types to cast this
const previous = RuntimeService.previousState?.[eventKey]; RuntimeService.previousState[key] = structuredClone(currentEntry);
const now = state[eventKey]; return true;
// if there was nothing, and there is nothing, noop
if (!previous?.id && !now?.id) return;
const eventChanged =
// if load status changed, save new
previous?.id !== now?.id ||
// maybe the event itself has changed
!deepEqual(RuntimeService.previousState?.[eventKey], state[eventKey]);
if (!eventChanged) return;
batch.add(eventKey, state[eventKey]);
RuntimeService.previousState[eventKey] = structuredClone(state[eventKey]);
} }
// save the restore state // save the restore state
@@ -772,8 +730,7 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
startedAt: state.timer.startedAt, startedAt: state.timer.startedAt,
addedTime: state.timer.addedTime, addedTime: state.timer.addedTime,
pausedAt: state._timer.pausedAt, pausedAt: state._timer.pausedAt,
firstStart: state.runtime.actualStart, firstStart: state.rundown.actualStart,
groupStartAt: state.groupNow?.startedAt ?? null,
}) })
.catch((_e) => { .catch((_e) => {
//we don't do anything with the error here //we don't do anything with the error here
@@ -1,16 +1,14 @@
import { millisToSeconds } from 'ontime-utils'; import { millisToSeconds } from 'ontime-utils';
import { import {
EntryId, EntryId,
GroupState,
isOntimeEvent, isOntimeEvent,
isPlayableEvent, isPlayableEvent,
MaybeNumber, MaybeNumber,
OntimeEvent, OntimeEvent,
Rundown, Rundown,
Runtime, Offset,
TimerState, TimerState,
TimerType, TimerType,
UpcomingEntry,
} from 'ontime-types'; } from 'ontime-types';
import { deepEqual } from 'fast-equals'; import { deepEqual } from 'fast-equals';
@@ -55,51 +53,15 @@ export function getShouldTimerUpdate(previousValue: TimerState | undefined, curr
); );
} }
export function getShouldRuntimeUpdate( export function getShouldOffsetUpdate(
previousValue: Runtime | undefined, previousValue: Offset | undefined,
currentValue: Runtime, currentValue: Offset,
didDependencyUpdate: boolean, didDependencyUpdate: boolean,
): boolean { ): boolean {
if (previousValue === undefined) return true; if (previousValue === undefined) return true;
if (didDependencyUpdate) return !deepEqual(previousValue, currentValue); if (previousValue.mode !== currentValue.mode) return true;
// absolute, relative, expected*End are ticked with `didDependencyUpdate`
return ( return didDependencyUpdate && !deepEqual(previousValue, currentValue);
previousValue.selectedEventIndex !== currentValue.selectedEventIndex ||
previousValue.numEvents !== currentValue.numEvents ||
previousValue.plannedStart !== currentValue.plannedStart ||
previousValue.plannedEnd !== currentValue.plannedEnd ||
previousValue.actualStart !== currentValue.actualStart ||
previousValue.offsetMode !== currentValue.offsetMode
// offsetAbs, offsetRel and expectedEnd are ticked with `didDependencyUpdate`
);
}
export function getShouldGroupUpdate(
previousValue: GroupState | null | undefined,
currentValue: GroupState | null,
didDependencyUpdate: boolean,
): boolean {
if (previousValue === undefined) return true;
if (didDependencyUpdate) return !deepEqual(previousValue, currentValue);
return (
previousValue?.id !== currentValue?.id || previousValue?.startedAt !== currentValue?.startedAt
// expectedEnd are ticked with `didDependencyUpdate`
);
}
export function getShouldFlagUpdate(
previousValue: UpcomingEntry | null | undefined,
currentValue: UpcomingEntry | null,
didDependencyUpdate: boolean,
): boolean {
if (previousValue === undefined) return true;
if (didDependencyUpdate) return !deepEqual(previousValue, currentValue);
return (
previousValue?.id !== currentValue?.id
// expectedStart
);
} }
/** /**
+8 -8
View File
@@ -112,23 +112,23 @@ export function skippedOutOfEvent(state: RuntimeState, previousTime: number, ski
* Positive offset is over time / behind schedule * Positive offset is over time / behind schedule
* Negative offset is under time / ahead of schedule * Negative offset is under time / ahead of schedule
*/ */
export function getRuntimeOffset(state: RuntimeState): { offsetAbs: number; offsetRel: number } { export function getRuntimeOffset(state: RuntimeState): { absolute: number; relative: number } {
const { eventNow, clock } = state; const { eventNow, clock } = state;
const { addedTime, current, startedAt } = state.timer; const { addedTime, current, startedAt } = state.timer;
// nothing to calculate if there are no loaded events or if we havent started // nothing to calculate if there are no loaded events or if we havent started
if (eventNow === null || startedAt === null) { if (eventNow === null || startedAt === null) {
return { offsetAbs: 0, offsetRel: 0 }; return { absolute: 0, relative: 0 };
} }
const { countToEnd, timeStart } = eventNow; const { countToEnd, timeStart } = eventNow;
const { plannedStart, actualStart } = state.runtime; const { plannedStart, actualStart } = state.rundown;
// eslint-disable-next-line no-unused-labels -- dev code path // eslint-disable-next-line no-unused-labels -- dev code path
DEV: { DEV: {
// we know current exists as long as eventNow exists // we know current exists as long as eventNow exists
if (current === null) throw new Error('timerUtils.getRuntimeOffset: state.timer.current must be set'); if (current === null) throw new Error('timerUtils.getRuntimeOffset: state.timer.current must be set');
if (plannedStart === null) throw new Error('timerUtils.getRuntimeOffset: state.runtime.plannedStart must be set'); if (plannedStart === null) throw new Error('timerUtils.getRuntimeOffset: state.rundown.plannedStart must be set');
if (actualStart === null) throw new Error('timerUtils.getRuntimeOffset: state.runtime.actualStart must be set'); if (actualStart === null) throw new Error('timerUtils.getRuntimeOffset: state.rundown.plannedStart must be set');
} }
// difference between planned event start and actual event start (will be positive if we stared behind ) // difference between planned event start and actual event start (will be positive if we stared behind )
@@ -140,13 +140,13 @@ export function getRuntimeOffset(state: RuntimeState): { offsetAbs: number; offs
// time the playback was paused, the different from now to when we paused is added to the offset TODO: brakes when crossing midnight // time the playback was paused, the different from now to when we paused is added to the offset TODO: brakes when crossing midnight
const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt; const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt;
const offsetAbs = eventStartOffset + overtime + pausedTime + addedTime; const absolute = eventStartOffset + overtime + pausedTime + addedTime;
// the relative offset i the same as the absolute offset but adjusted relative to the actual start time // the relative offset i the same as the absolute offset but adjusted relative to the actual start time
const offsetRel = offsetAbs + plannedStart - actualStart; const relative = absolute + plannedStart - actualStart;
// in case of count to end, the absolute offset is just the overtime // in case of count to end, the absolute offset is just the overtime
return countToEnd ? { offsetAbs: overtime, offsetRel } : { offsetAbs, offsetRel }; return countToEnd ? { absolute: overtime, relative } : { absolute, relative };
} }
/** /**
@@ -6,19 +6,22 @@ const baseState: RuntimeState = {
clock: 0, clock: 0,
eventNow: null, eventNow: null,
eventNext: null, eventNext: null,
eventFlag: null,
groupNow: null, groupNow: null,
groupNext: null, rundown: {
nextFlag: null,
runtime: {
selectedEventIndex: null, selectedEventIndex: null,
numEvents: 0, numEvents: 0,
offsetAbs: 0,
offsetRel: 0,
plannedStart: 0, plannedStart: 0,
plannedEnd: 0, plannedEnd: 0,
actualStart: null, actualStart: null,
expectedEnd: null, },
offsetMode: OffsetMode.Absolute, offset: {
absolute: 0,
relative: 0,
mode: OffsetMode.Absolute,
expectedRundownEnd: null,
expectedGroupEnd: null,
expectedFlagStart: null,
}, },
timer: { timer: {
addedTime: 0, addedTime: 0,
@@ -33,7 +33,7 @@ const mockState = {
clock: 666, clock: 666,
eventNow: null, eventNow: null,
eventNext: null, eventNext: null,
runtime: { rundown: {
selectedEventIndex: null, selectedEventIndex: null,
numEvents: 0, numEvents: 0,
}, },
@@ -116,7 +116,7 @@ describe('mutation on runtimeState', () => {
expect(newState.timer).toMatchObject({ expect(newState.timer).toMatchObject({
playback: Playback.Play, playback: Playback.Play,
}); });
expect(newState.runtime.actualStart).toBe(newState.clock); expect(newState.rundown.actualStart).toBe(newState.clock);
// 3. Pause event // 3. Pause event
vi.setSystemTime('jan 1 00:03'); vi.setSystemTime('jan 1 00:03');
@@ -166,7 +166,7 @@ describe('mutation on runtimeState', () => {
expectedFinish: null, expectedFinish: null,
startedAt: null, startedAt: null,
}); });
expect(newState.runtime.actualStart).toBeNull(); expect(newState.rundown.actualStart).toBeNull();
}); });
}); });
@@ -187,24 +187,24 @@ describe('mutation on runtimeState', () => {
vi.setSystemTime('jan 1 00:09'); vi.setSystemTime('jan 1 00:09');
load(entries.event1, rundown, metadata); load(entries.event1, rundown, metadata);
let newState = getState(); let newState = getState();
expect(newState.runtime.actualStart).toBeNull(); expect(newState.rundown.actualStart).toBeNull();
expect(newState.runtime.plannedStart).toBe(0); expect(newState.rundown.plannedStart).toBe(0);
expect(newState.runtime.plannedEnd).toBe(1500); expect(newState.rundown.plannedEnd).toBe(1500);
expect(newState.groupNow).toBeNull(); expect(newState.groupNow).toBeNull();
expect(newState.runtime.offsetAbs).toBe(0); expect(newState.offset.absolute).toBe(0);
// 2. Start event // 2. Start event
vi.setSystemTime('jan 1 00:10'); vi.setSystemTime('jan 1 00:10');
start(); start();
newState = getState(); newState = getState();
const firstStart = newState.clock; const firstStart = newState.clock;
if (newState.runtime.offsetAbs === null) { if (newState.offset.absolute === null) {
throw new Error('Value cannot be null at this stage'); throw new Error('Value cannot be null at this stage');
} }
expect(newState.runtime.actualStart).toBe(newState.clock); expect(newState.rundown.actualStart).toBe(newState.clock);
expect(newState.runtime.offsetAbs).toBe(newState.clock - entries.event1.timeStart); expect(newState.offset.absolute).toBe(newState.clock - entries.event1.timeStart);
expect(newState.runtime.expectedEnd).toBe(entries.event2.timeEnd + newState.runtime.offsetAbs); expect(newState.offset.expectedRundownEnd).toBe(entries.event2.timeEnd + newState.offset.absolute);
// 3. Next event // 3. Next event
vi.setSystemTime('jan 1 00:12'); vi.setSystemTime('jan 1 00:12');
@@ -212,37 +212,37 @@ describe('mutation on runtimeState', () => {
start(); start();
newState = getState(); newState = getState();
if (newState.runtime.actualStart === null || newState.runtime.offsetAbs === null) { if (newState.rundown.actualStart === null || newState.offset.absolute === null) {
throw new Error('Value cannot be null at this stage'); throw new Error('Value cannot be null at this stage');
} }
// there is a case where the calculation time overflows the millisecond which makes // there is a case where the calculation time overflows the millisecond which makes
// tests fail // tests fail
const forgivingActualStart = Math.abs(newState.runtime.actualStart - firstStart); const forgivingActualStart = Math.abs(newState.rundown.actualStart - firstStart);
expect(forgivingActualStart).toBeLessThanOrEqual(1); expect(forgivingActualStart).toBeLessThanOrEqual(1);
// we are over-under, the difference between the schedule and the actual start // we are over-under, the difference between the schedule and the actual start
const delayBefore = newState.clock - entries.event2.timeStart; const delayBefore = newState.clock - entries.event2.timeStart;
expect(newState.runtime.offsetAbs).toBe(delayBefore); expect(newState.offset.absolute).toBe(delayBefore);
// finish is the difference between the runtime and the schedule // finish is the difference between the runtime and the schedule
expect(newState.runtime.expectedEnd).toBe(newState.runtime.offsetAbs + entries.event2.timeEnd); expect(newState.offset.expectedRundownEnd).toBe(newState.offset.absolute + entries.event2.timeEnd);
expect(newState.groupNow).toBeNull(); expect(newState.groupNow).toBeNull();
// 4. Add time // 4. Add time
addTime(10); addTime(10);
newState = getState(); newState = getState();
if (newState.runtime.offsetAbs === null) { if (newState.offset.absolute === null) {
throw new Error('Value cannot be null at this stage'); throw new Error('Value cannot be null at this stage');
} }
expect(newState.runtime.offsetAbs).toBe(delayBefore + 10); expect(newState.offset.absolute).toBe(delayBefore + 10);
expect(newState.runtime.expectedEnd).toBe(entries.event2.timeEnd + newState.runtime.offsetAbs); expect(newState.offset.expectedRundownEnd).toBe(entries.event2.timeEnd + newState.offset.absolute);
// 5. Stop event // 5. Stop event
stop(); stop();
newState = getState(); newState = getState();
expect(newState.runtime.actualStart).toBeNull(); expect(newState.rundown.actualStart).toBeNull();
expect(newState.runtime.offsetAbs).toBe(0); expect(newState.offset.absolute).toBe(0);
expect(newState.runtime.expectedEnd).toBeNull(); expect(newState.offset.expectedRundownEnd).toBeNull();
}); });
}); });
@@ -332,7 +332,7 @@ describe('roll mode', () => {
start(); start();
const result = roll(rundown, metadata); const result = roll(rundown, metadata);
expect(result).toStrictEqual({ eventId: '1', didStart: false }); expect(result).toStrictEqual({ eventId: '1', didStart: false });
expect(getState().runtime.offsetAbs).toBe(-1000); expect(getState().offset.absolute).toBe(-1000);
}); });
}); });
@@ -356,29 +356,29 @@ describe('roll mode', () => {
load(rundown.entries[1] as PlayableEvent, rundown, metadata); load(rundown.entries[1] as PlayableEvent, rundown, metadata);
start(); start();
// the current offset after manual play // the current offset after manual play
const currentOffset = getState().runtime.offsetAbs; const currentOffset = getState().offset.absolute;
let result = roll(rundown, metadata, getState().runtime.offsetAbs); let result = roll(rundown, metadata, getState().offset.absolute);
expect(result).toStrictEqual({ eventId: '1', didStart: false }); expect(result).toStrictEqual({ eventId: '1', didStart: false });
// the current offset should be maintain by roll mode when taking over from play // the current offset should be maintain by roll mode when taking over from play
expect(getState().runtime.offsetAbs).toBe(currentOffset); expect(getState().offset.absolute).toBe(currentOffset);
vi.setSystemTime('jan 1 00:00:01'); vi.setSystemTime('jan 1 00:00:01');
result = roll(rundown, metadata, getState().runtime.offsetAbs); result = roll(rundown, metadata, getState().offset.absolute);
expect(result).toStrictEqual({ eventId: '2', didStart: true }); expect(result).toStrictEqual({ eventId: '2', didStart: true });
expect(getState().runtime.offsetAbs).toBe(-1000); expect(getState().offset.absolute).toBe(-1000);
vi.setSystemTime('jan 1 00:00:02'); vi.setSystemTime('jan 1 00:00:02');
result = roll(rundown, metadata, getState().runtime.offsetAbs); result = roll(rundown, metadata, getState().offset.absolute);
expect(result).toStrictEqual({ eventId: '3', didStart: true }); expect(result).toStrictEqual({ eventId: '3', didStart: true });
expect(getState().runtime.offsetAbs).toBe(-1000); expect(getState().offset.absolute).toBe(-1000);
vi.useRealTimers(); vi.useRealTimers();
}); });
}); });
}); });
describe('loadGroup', () => { describe('loadGroupFlagAndEnd()', () => {
test('from no-group to a group will clear startedAt', () => { test('from no-group to a group will clear expectedGroupEnd', () => {
const rundown = makeRundown({ const rundown = makeRundown({
entries: { entries: {
0: makeOntimeEvent({ id: '0', parent: null }), 0: makeOntimeEvent({ id: '0', parent: null }),
@@ -393,6 +393,7 @@ describe('loadGroup', () => {
const state = { const state = {
groupNow: null, groupNow: null,
eventNow: rundown.entries[11], eventNow: rundown.entries[11],
offset: { expectedGroupEnd: 123 },
} as RuntimeState; } as RuntimeState;
const metadata = { playableEventOrder: ['0', '11', '3'], flags: ['1'] } as RundownMetadata; const metadata = { playableEventOrder: ['0', '11', '3'], flags: ['1'] } as RundownMetadata;
@@ -400,12 +401,13 @@ describe('loadGroup', () => {
loadGroupFlagAndEnd(rundown, metadata, 2, state); loadGroupFlagAndEnd(rundown, metadata, 2, state);
expect(state).toMatchObject({ expect(state).toMatchObject({
groupNow: { id: rundown.entries[1].id, startedAt: null }, groupNow: rundown.entries[1],
offset: { expectedGroupEnd: null },
eventNow: rundown.entries[11], eventNow: rundown.entries[11],
}); });
}); });
test('from a group to a different group will clear startedAt', () => { test('from a group to a different group will clear expectedGroupEnd', () => {
const rundown = makeRundown({ const rundown = makeRundown({
entries: { entries: {
0: makeOntimeEvent({ id: '0', parent: null }), 0: makeOntimeEvent({ id: '0', parent: null }),
@@ -418,7 +420,8 @@ describe('loadGroup', () => {
}); });
const state = { const state = {
groupNow: { id: rundown.entries[1].id, startedAt: 123 }, groupNow: rundown.entries[1],
offset: { expectedGroupEnd: 123 },
eventNow: rundown.entries[22], eventNow: rundown.entries[22],
} as RuntimeState; } as RuntimeState;
@@ -427,12 +430,13 @@ describe('loadGroup', () => {
loadGroupFlagAndEnd(rundown, metadata, 1, state); loadGroupFlagAndEnd(rundown, metadata, 1, state);
expect(state).toMatchObject({ expect(state).toMatchObject({
groupNow: { id: rundown.entries[2].id, startedAt: null }, groupNow: rundown.entries[2],
offset: { expectedGroupEnd: null },
eventNow: rundown.entries[22], eventNow: rundown.entries[22],
}); });
}); });
test('from group to a no-group will clear startedAt', () => { test('from group to a no-group will clear expectedGroupEnd', () => {
const rundown = makeRundown({ const rundown = makeRundown({
entries: { entries: {
0: makeOntimeEvent({ id: '0', parent: null }), 0: makeOntimeEvent({ id: '0', parent: null }),
@@ -445,10 +449,8 @@ describe('loadGroup', () => {
}); });
const state = { const state = {
groupNow: { groupNow: rundown.entries[1],
id: rundown.entries[1].id, offset: { expectedGroupEnd: 123 },
startedAt: 123,
},
eventNow: rundown.entries[0], eventNow: rundown.entries[0],
} as RuntimeState; } as RuntimeState;
@@ -458,35 +460,11 @@ describe('loadGroup', () => {
expect(state).toMatchObject({ expect(state).toMatchObject({
groupNow: null, groupNow: null,
offset: { expectedGroupEnd: null },
eventNow: rundown.entries[0], eventNow: rundown.entries[0],
}); });
}); });
test('from a group to same group will keep startedAt', () => {
const rundown = makeRundown({
entries: {
0: makeOntimeGroup({ id: '0', entries: ['1', '2'] }),
1: makeOntimeEvent({ id: '1', parent: '0' }),
2: makeOntimeEvent({ id: '2', parent: '0' }),
},
order: ['0'],
});
const state = {
groupNow: { id: rundown.entries[0].id, startedAt: 123 },
eventNow: rundown.entries[2],
} as RuntimeState;
const metadata = { playableEventOrder: ['1', '2'], flags: ['1'] } as RundownMetadata;
loadGroupFlagAndEnd(rundown, metadata, 0, state);
expect(state).toMatchObject({
groupNow: { id: rundown.entries[0].id, startedAt: 123 },
eventNow: rundown.entries[2],
});
});
test('from no-group to no-group will keep startedAt', () => { test('from no-group to no-group will keep startedAt', () => {
const rundown = makeRundown({ const rundown = makeRundown({
entries: { entries: {
+102 -129
View File
@@ -1,6 +1,4 @@
import { import {
GroupState,
UpcomingEntry,
isOntimeEvent, isOntimeEvent,
MaybeNumber, MaybeNumber,
MaybeString, MaybeString,
@@ -10,10 +8,11 @@ import {
PlayableEvent, PlayableEvent,
Playback, Playback,
Rundown, Rundown,
Runtime, Offset,
runtimeStorePlaceholder, runtimeStorePlaceholder,
TimerPhase, TimerPhase,
TimerState, TimerState,
RundownState,
} from 'ontime-types'; } from 'ontime-types';
import { import {
calculateDuration, calculateDuration,
@@ -36,13 +35,13 @@ type ExpectedMetadata = { event: OntimeEvent; accumulatedGap: number; isLinkedTo
export type RuntimeState = { export type RuntimeState = {
clock: number; // realtime clock clock: number; // realtime clock
groupNow: GroupState | null; groupNow: OntimeGroup | null;
groupNext: MaybeString;
nextFlag: UpcomingEntry | null;
eventNow: PlayableEvent | null; eventNow: PlayableEvent | null;
eventNext: PlayableEvent | null; eventNext: PlayableEvent | null;
runtime: Runtime; eventFlag: PlayableEvent | null;
offset: Offset;
timer: TimerState; timer: TimerState;
rundown: RundownState;
// private properties of the timer calculations // private properties of the timer calculations
_timer: { _timer: {
forceFinish: MaybeNumber; // whether we should declare an event as finished, will contain the finish time forceFinish: MaybeNumber; // whether we should declare an event as finished, will contain the finish time
@@ -61,12 +60,12 @@ export type RuntimeState = {
const runtimeState: RuntimeState = { const runtimeState: RuntimeState = {
clock: timeNow(), clock: timeNow(),
groupNow: null, groupNow: null,
groupNext: null,
nextFlag: null,
eventNow: null, eventNow: null,
eventNext: null, eventNext: null,
runtime: { ...runtimeStorePlaceholder.runtime }, eventFlag: null,
offset: { ...runtimeStorePlaceholder.offset },
timer: { ...runtimeStorePlaceholder.timer }, timer: { ...runtimeStorePlaceholder.timer },
rundown: { ...runtimeStorePlaceholder.rundown },
_timer: { _timer: {
forceFinish: null, forceFinish: null,
pausedAt: null, pausedAt: null,
@@ -87,7 +86,10 @@ export function getState(): Readonly<RuntimeState> {
...runtimeState, ...runtimeState,
eventNow: runtimeState.eventNow ? { ...runtimeState.eventNow } : null, eventNow: runtimeState.eventNow ? { ...runtimeState.eventNow } : null,
eventNext: runtimeState.eventNext ? { ...runtimeState.eventNext } : null, eventNext: runtimeState.eventNext ? { ...runtimeState.eventNext } : null,
runtime: { ...runtimeState.runtime }, eventFlag: runtimeState.eventFlag ? { ...runtimeState.eventFlag } : null,
groupNow: runtimeState.groupNow ? { ...runtimeState.groupNow } : null,
offset: { ...runtimeState.offset },
rundown: { ...runtimeState.rundown },
timer: { ...runtimeState.timer }, timer: { ...runtimeState.timer },
_timer: { ...runtimeState._timer }, _timer: { ...runtimeState._timer },
_rundown: { ...runtimeState._rundown }, _rundown: { ...runtimeState._rundown },
@@ -101,13 +103,16 @@ export function clearEventData() {
runtimeState.eventNow = null; runtimeState.eventNow = null;
runtimeState.eventNext = null; runtimeState.eventNext = null;
runtimeState.runtime.offsetAbs = 0; runtimeState.offset.absolute = 0;
runtimeState.runtime.offsetRel = 0; runtimeState.offset.relative = 0;
runtimeState.runtime.expectedEnd = null; runtimeState.offset.expectedFlagStart = null;
runtimeState.runtime.selectedEventIndex = null;
runtimeState.rundown.selectedEventIndex = null;
//TODO: is there any ExpectedMetadata stuff we need to clear here //TODO: is there any ExpectedMetadata stuff we need to clear here
if (runtimeState.groupNow) runtimeState.groupNow.expectedEnd = null; runtimeState.offset.expectedGroupEnd = null;
runtimeState.offset.expectedFlagStart = null;
runtimeState.offset.expectedRundownEnd = null;
runtimeState.timer.playback = Playback.Stop; runtimeState.timer.playback = Playback.Stop;
runtimeState.clock = timeNow(); runtimeState.clock = timeNow();
@@ -124,20 +129,22 @@ export function clearEventData() {
export function clearState() { export function clearState() {
runtimeState.eventNow = null; runtimeState.eventNow = null;
runtimeState.eventNext = null; runtimeState.eventNext = null;
runtimeState.eventFlag = null;
runtimeState.groupNow = null;
runtimeState.groupNext = null;
runtimeState._group = null;
runtimeState.nextFlag = null;
runtimeState._flag = null; runtimeState._flag = null;
runtimeState.runtime.offsetAbs = 0; runtimeState.groupNow = null;
runtimeState.runtime.offsetRel = 0; runtimeState._group = null;
runtimeState.runtime.actualStart = null;
runtimeState.runtime.expectedEnd = null; runtimeState.rundown.actualStart = null;
runtimeState.rundown.selectedEventIndex = null;
runtimeState.offset.absolute = 0;
runtimeState.offset.relative = 0;
runtimeState.offset.expectedRundownEnd = null;
runtimeState.offset.expectedGroupEnd = null;
runtimeState.offset.expectedFlagStart = null;
runtimeState._end = null; runtimeState._end = null;
runtimeState.runtime.selectedEventIndex = null;
runtimeState.timer.playback = Playback.Stop; runtimeState.timer.playback = Playback.Stop;
runtimeState.clock = timeNow(); runtimeState.clock = timeNow();
@@ -168,25 +175,23 @@ function patchTimer(newState: Partial<TimerState & RestorePoint>) {
} }
} }
type RundownData = { /**
* Utility, allows updating data derived from the rundown
* @param playableRundown
*/
export function updateRundownData(rundownData: {
numEvents: number; // length of rundown filtered for timed events numEvents: number; // length of rundown filtered for timed events
firstStart: MaybeNumber; firstStart: MaybeNumber;
lastEnd: MaybeNumber; lastEnd: MaybeNumber;
totalDelay: number; totalDelay: number;
totalDuration: number; totalDuration: number;
}; }) {
/**
* Utility, allows updating data derived from the rundown
* @param playableRundown
*/
export function updateRundownData(rundownData: RundownData) {
// we keep this in private state since there is no UI use case for it // we keep this in private state since there is no UI use case for it
runtimeState._rundown.totalDelay = rundownData.totalDelay; runtimeState._rundown.totalDelay = rundownData.totalDelay;
runtimeState.runtime.numEvents = rundownData.numEvents; runtimeState.rundown.numEvents = rundownData.numEvents;
runtimeState.runtime.plannedStart = rundownData.firstStart; runtimeState.rundown.plannedStart = rundownData.firstStart;
runtimeState.runtime.plannedEnd = runtimeState.rundown.plannedEnd =
rundownData.firstStart === null ? null : rundownData.firstStart + rundownData.totalDuration; rundownData.firstStart === null ? null : rundownData.firstStart + rundownData.totalDuration;
getExpectedTimes(); getExpectedTimes();
} }
@@ -222,22 +227,19 @@ export function load(
runtimeState.timer.playback = Playback.Armed; runtimeState.timer.playback = Playback.Armed;
runtimeState.timer.duration = calculateDuration(event.timeStart, event.timeEnd); runtimeState.timer.duration = calculateDuration(event.timeStart, event.timeEnd);
runtimeState.timer.current = getCurrent(runtimeState); runtimeState.timer.current = getCurrent(runtimeState);
runtimeState.runtime.numEvents = metadata.timedEventOrder.length; runtimeState.rundown.numEvents = metadata.timedEventOrder.length;
// patch with potential provided data // patch with potential provided data
if (initialData) { if (initialData) {
patchTimer(initialData); patchTimer(initialData);
const firstStart = initialData?.firstStart; const firstStart = initialData?.firstStart;
if (firstStart === null || typeof firstStart === 'number') { if (firstStart === null || typeof firstStart === 'number') {
runtimeState.runtime.actualStart = firstStart; runtimeState.rundown.actualStart = firstStart;
const { offsetAbs, offsetRel } = getRuntimeOffset(runtimeState); const { absolute, relative } = getRuntimeOffset(runtimeState);
runtimeState.runtime.offsetAbs = offsetAbs; runtimeState.offset.absolute = absolute;
runtimeState.runtime.offsetRel = offsetRel; runtimeState.offset.relative = relative;
getExpectedTimes(); getExpectedTimes();
} }
if (typeof initialData.groupStartAt === 'number' && runtimeState.groupNow) {
runtimeState.groupNow.startedAt = initialData.groupStartAt;
}
} }
return event.id === runtimeState.eventNow?.id; return event.id === runtimeState.eventNow?.id;
} }
@@ -248,17 +250,17 @@ export function load(
export function loadNow( export function loadNow(
rundown: Rundown, rundown: Rundown,
metadata: RundownMetadata, metadata: RundownMetadata,
eventIndex: MaybeNumber = runtimeState.runtime.selectedEventIndex, eventIndex: MaybeNumber = runtimeState.rundown.selectedEventIndex,
) { ) {
if (eventIndex === null) { if (eventIndex === null) {
// reset the state to indicate there is no selection // reset the state to indicate there is no selection
runtimeState.runtime.selectedEventIndex = null; runtimeState.rundown.selectedEventIndex = null;
runtimeState.eventNow = null; runtimeState.eventNow = null;
return; return;
} }
const event = rundown.entries[metadata.timedEventOrder[eventIndex]] as PlayableEvent; const event = rundown.entries[metadata.timedEventOrder[eventIndex]] as PlayableEvent;
runtimeState.runtime.selectedEventIndex = eventIndex; runtimeState.rundown.selectedEventIndex = eventIndex;
runtimeState.eventNow = event; runtimeState.eventNow = event;
} }
@@ -268,7 +270,7 @@ export function loadNow(
export function loadNext( export function loadNext(
rundown: Rundown, rundown: Rundown,
metadata: RundownMetadata, metadata: RundownMetadata,
eventIndex: MaybeNumber = runtimeState.runtime.selectedEventIndex, eventIndex: MaybeNumber = runtimeState.rundown.selectedEventIndex,
) { ) {
if (eventIndex === null) { if (eventIndex === null) {
// reset the state to indicate there is no future event // reset the state to indicate there is no future event
@@ -314,7 +316,7 @@ export function updateLoaded(event?: PlayableEvent): string | undefined {
// handle edge cases with roll // handle edge cases with roll
if (runtimeState.timer.playback === Playback.Roll) { if (runtimeState.timer.playback === Playback.Roll) {
const offsetClock = runtimeState.clock - runtimeState.runtime.offsetAbs; const offsetClock = runtimeState.clock - runtimeState.offset.absolute;
// if waiting to roll, we update the targets and potentially start the timer // if waiting to roll, we update the targets and potentially start the timer
if (runtimeState._timer.secondaryTarget !== null) { if (runtimeState._timer.secondaryTarget !== null) {
if (runtimeState.eventNow.timeStart < offsetClock && offsetClock < runtimeState.eventNow.timeEnd) { if (runtimeState.eventNow.timeStart < offsetClock && offsetClock < runtimeState.eventNow.timeEnd) {
@@ -358,13 +360,6 @@ export function updateAll(rundown: Rundown, metadata: RundownMetadata) {
loadNext(rundown, metadata, eventNowIndex >= 0 ? eventNowIndex : undefined); loadNext(rundown, metadata, eventNowIndex >= 0 ? eventNowIndex : undefined);
updateLoaded(runtimeState.eventNow ?? undefined); updateLoaded(runtimeState.eventNow ?? undefined);
loadGroupFlagAndEnd(rundown, metadata, eventNowIndex); loadGroupFlagAndEnd(rundown, metadata, eventNowIndex);
// catch the edge case where the playing event is moved into a group
// if the group already has a start value then we dont overwrite it
// use the start value from the timer, so if the we are not running it will be set to null
if (runtimeState.groupNow && runtimeState.groupNow.startedAt === null) {
runtimeState.groupNow.startedAt = runtimeState.timer.startedAt;
}
} }
export function start(state: RuntimeState = runtimeState): boolean { export function start(state: RuntimeState = runtimeState): boolean {
@@ -389,31 +384,26 @@ export function start(state: RuntimeState = runtimeState): boolean {
state.timer.startedAt = state.clock; state.timer.startedAt = state.clock;
} }
// update group start time
if (state.groupNow && state.groupNow.startedAt === null) {
state.groupNow.startedAt = state.clock;
}
state.timer.playback = Playback.Play; state.timer.playback = Playback.Play;
state.timer.expectedFinish = getExpectedFinish(state); state.timer.expectedFinish = getExpectedFinish(state);
state.timer.elapsed = 0; state.timer.elapsed = 0;
if (state.runtime.actualStart === null) { if (state.rundown.actualStart === null) {
state.runtime.actualStart = state.clock; state.rundown.actualStart = state.clock;
} }
// update timer phase // update timer phase
runtimeState.timer.phase = getTimerPhase(runtimeState); runtimeState.timer.phase = getTimerPhase(runtimeState);
// update offset // update offset
const { offsetAbs, offsetRel } = getRuntimeOffset(runtimeState); const { absolute, relative } = getRuntimeOffset(runtimeState);
runtimeState.runtime.offsetAbs = offsetAbs; runtimeState.offset.absolute = absolute;
runtimeState.runtime.offsetRel = offsetRel; runtimeState.offset.relative = relative;
// as long as there is a timer, we need an planned end // as long as there is a timer, we need an planned end
// eslint-disable-next-line no-unused-labels -- dev code path // eslint-disable-next-line no-unused-labels -- dev code path
DEV: { DEV: {
if (state.runtime.plannedEnd === null) { if (state.rundown.plannedEnd === null) {
throw new Error('runtimeState.start: invalid state received'); throw new Error('runtimeState.start: invalid state received');
} }
} }
@@ -477,9 +467,9 @@ export function addTime(amount: number) {
runtimeState.timer.current += amount; runtimeState.timer.current += amount;
// update runtime delays: over - under // update runtime delays: over - under
const { offsetAbs, offsetRel } = getRuntimeOffset(runtimeState); const { absolute, relative } = getRuntimeOffset(runtimeState);
runtimeState.runtime.offsetAbs = offsetAbs; runtimeState.offset.absolute = absolute;
runtimeState.runtime.offsetRel = offsetRel; runtimeState.offset.relative = relative;
getExpectedTimes(); getExpectedTimes();
return true; return true;
@@ -524,9 +514,9 @@ export function update(): UpdateResult {
runtimeState.timer.elapsed = runtimeState.timer.duration - runtimeState.timer.current; runtimeState.timer.elapsed = runtimeState.timer.duration - runtimeState.timer.current;
// update runtime, needs up-to-date timer state // update runtime, needs up-to-date timer state
const { offsetAbs, offsetRel } = getRuntimeOffset(runtimeState); const { absolute, relative } = getRuntimeOffset(runtimeState);
runtimeState.runtime.offsetAbs = offsetAbs; runtimeState.offset.absolute = absolute;
runtimeState.runtime.offsetRel = offsetRel; runtimeState.offset.relative = relative;
const finishedNow = const finishedNow =
Boolean(runtimeState._timer.forceFinish) || Boolean(runtimeState._timer.forceFinish) ||
@@ -556,7 +546,7 @@ export function update(): UpdateResult {
} }
// account for offset // account for offset
const offsetClock = runtimeState.clock + runtimeState.runtime.offsetAbs; const offsetClock = runtimeState.clock + runtimeState.offset.absolute;
runtimeState.timer.phase = TimerPhase.Pending; runtimeState.timer.phase = TimerPhase.Pending;
if (hasCrossedMidnight) { if (hasCrossedMidnight) {
@@ -576,7 +566,7 @@ export function roll(
offset = 0, offset = 0,
): { eventId: MaybeString; didStart: boolean } { ): { eventId: MaybeString; didStart: boolean } {
// 1. if an event is running, we simply take over the playback // 1. if an event is running, we simply take over the playback
if (runtimeState.timer.playback === Playback.Play && runtimeState.runtime.selectedEventIndex !== null) { if (runtimeState.timer.playback === Playback.Play && runtimeState.rundown.selectedEventIndex !== null) {
runtimeState.timer.playback = Playback.Roll; runtimeState.timer.playback = Playback.Roll;
return { eventId: runtimeState.eventNow?.id ?? null, didStart: false }; return { eventId: runtimeState.eventNow?.id ?? null, didStart: false };
} }
@@ -590,7 +580,7 @@ export function roll(
} }
} }
runtimeState.runtime.offsetAbs = offset; runtimeState.offset.absolute = offset;
runtimeState.timer.playback = Playback.Roll; runtimeState.timer.playback = Playback.Roll;
// account for event that finishes the day after // account for event that finishes the day after
@@ -601,7 +591,7 @@ export function roll(
runtimeState.timer.expectedFinish = normalisedEndTime; runtimeState.timer.expectedFinish = normalisedEndTime;
//account for offset //account for offset
const offsetClock = runtimeState.clock - runtimeState.runtime.offsetAbs; const offsetClock = runtimeState.clock - runtimeState.offset.absolute;
// state catch up // state catch up
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, normalisedEndTime); runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, normalisedEndTime);
@@ -613,12 +603,8 @@ export function roll(
if (isNow) { if (isNow) {
runtimeState.timer.startedAt = runtimeState.clock; runtimeState.timer.startedAt = runtimeState.clock;
// update runtime if (runtimeState.rundown.actualStart === null) {
if (runtimeState.groupNow && runtimeState.groupNow.startedAt === null) { runtimeState.rundown.actualStart = runtimeState.clock;
runtimeState.groupNow.startedAt = runtimeState.clock;
}
if (!runtimeState.runtime.actualStart) {
runtimeState.runtime.actualStart = runtimeState.clock;
} }
runtimeState.timer.secondaryTimer = null; runtimeState.timer.secondaryTimer = null;
} else { } else {
@@ -639,8 +625,8 @@ export function roll(
clearEventData(); clearEventData();
//account for offset but we only keep it if passed to us //account for offset but we only keep it if passed to us
runtimeState.runtime.offsetAbs = offset; runtimeState.offset.absolute = offset;
const offsetClock = runtimeState.clock - runtimeState.runtime.offsetAbs; const offsetClock = runtimeState.clock - runtimeState.offset.absolute;
const { index, isPending } = loadRoll(rundown, metadata, offsetClock); const { index, isPending } = loadRoll(rundown, metadata, offsetClock);
@@ -651,7 +637,7 @@ export function roll(
// update roll state // update roll state
runtimeState.timer.playback = Playback.Roll; runtimeState.timer.playback = Playback.Roll;
runtimeState.runtime.numEvents = metadata.timedEventOrder.length; runtimeState.rundown.numEvents = metadata.timedEventOrder.length;
// in roll mode spec, there should always be something to load // in roll mode spec, there should always be something to load
// as long as playableEvents is not empty // as long as playableEvents is not empty
@@ -676,12 +662,6 @@ export function roll(
} }
// there is something to run, load event // there is something to run, load event
// update runtime
if (runtimeState.groupNow && runtimeState.groupNow.startedAt === null) {
runtimeState.groupNow.startedAt = runtimeState.clock;
}
// event will finish on time // event will finish on time
// account for event that finishes the day after // account for event that finishes the day after
const endTime = const endTime =
@@ -700,25 +680,26 @@ export function roll(
runtimeState.timer.elapsed = 0; runtimeState.timer.elapsed = 0;
// update runtime // update runtime
runtimeState.runtime.actualStart = runtimeState.clock; runtimeState.rundown.actualStart = runtimeState.clock;
return { eventId: runtimeState.eventNow.id, didStart: true }; return { eventId: runtimeState.eventNow.id, didStart: true };
} }
/** /**
* calculates and sets values directly in state * calculates and sets values directly in state
* - runtime.expectedEnd * - offset.expectedRundownEnd
* - groupNow.expectedEnd * - offset.expectedGroupEnd
* - nextFlag.expectedStart * - offset.expectedFlagStart
*/ */
function getExpectedTimes(state = runtimeState) { function getExpectedTimes(state = runtimeState) {
const { offsetMode, offsetAbs, offsetRel, plannedStart, actualStart } = state.runtime; const { offset } = state;
const { plannedStart, actualStart } = state.rundown;
const { eventNow } = state; const { eventNow } = state;
if (!eventNow) return; if (!eventNow) return;
state.runtime.expectedEnd = null; state.offset.expectedRundownEnd = null;
if (state.groupNow) { if (state.groupNow) {
state.groupNow.expectedEnd = null; state.offset.expectedGroupEnd = null;
const { _group } = state; const { _group } = state;
if (_group !== null) { if (_group !== null) {
const { event: lastEvent, accumulatedGap, isLinkedToLoaded } = _group; const { event: lastEvent, accumulatedGap, isLinkedToLoaded } = _group;
@@ -726,17 +707,17 @@ function getExpectedTimes(state = runtimeState) {
currentDay: eventNow.dayOffset, currentDay: eventNow.dayOffset,
totalGap: accumulatedGap, totalGap: accumulatedGap,
isLinkedToLoaded, isLinkedToLoaded,
offsetMode, mode: offset.mode,
offset: offsetMode === OffsetMode.Absolute ? offsetAbs : offsetRel, offset: offset.mode === OffsetMode.Absolute ? offset.absolute : offset.relative,
plannedStart, plannedStart,
actualStart, actualStart,
}); });
state.groupNow.expectedEnd = lastEventExpectedStart + lastEvent.duration; state.offset.expectedGroupEnd = lastEventExpectedStart + lastEvent.duration;
} }
} }
if (state.nextFlag) { if (state.eventFlag) {
state.nextFlag.expectedStart = null; state.offset.expectedFlagStart = null;
const { _flag } = state; const { _flag } = state;
if (_flag) { if (_flag) {
const { event, accumulatedGap, isLinkedToLoaded } = _flag; const { event, accumulatedGap, isLinkedToLoaded } = _flag;
@@ -744,12 +725,12 @@ function getExpectedTimes(state = runtimeState) {
currentDay: eventNow.dayOffset, currentDay: eventNow.dayOffset,
totalGap: accumulatedGap, totalGap: accumulatedGap,
isLinkedToLoaded, isLinkedToLoaded,
offsetMode, mode: offset.mode,
offset: offsetMode === OffsetMode.Absolute ? offsetAbs : offsetRel, offset: offset.mode === OffsetMode.Absolute ? offset.absolute : offset.relative,
plannedStart, plannedStart,
actualStart, actualStart,
}); });
state.nextFlag.expectedStart = expectedStart; state.offset.expectedFlagStart = expectedStart;
} }
} }
@@ -759,14 +740,14 @@ function getExpectedTimes(state = runtimeState) {
currentDay: eventNow.dayOffset, currentDay: eventNow.dayOffset,
totalGap: accumulatedGap, totalGap: accumulatedGap,
isLinkedToLoaded, isLinkedToLoaded,
offsetMode, mode: offset.mode,
offset: offsetMode === OffsetMode.Absolute ? offsetAbs : offsetRel, offset: offset.mode === OffsetMode.Absolute ? offset.absolute : offset.relative,
plannedStart, plannedStart,
actualStart, actualStart,
}); });
state.runtime.expectedEnd = expectedStart + event.duration; state.offset.expectedRundownEnd = expectedStart + event.duration;
} else { } else {
state.runtime.expectedEnd = null; state.offset.expectedRundownEnd = null;
} }
} }
@@ -790,7 +771,6 @@ export function loadGroupFlagAndEnd(
// if we don't have a any flags in the rundown then no need to look for it // if we don't have a any flags in the rundown then no need to look for it
let foundFlag = !flagsPresent; let foundFlag = !flagsPresent;
let foundNextGroup = false;
// if we don't have a last event for the group there is no need to find its end time // if we don't have a last event for the group there is no need to find its end time
let foundGroupEnd = lastEventInGroup === null; let foundGroupEnd = lastEventInGroup === null;
@@ -809,7 +789,7 @@ export function loadGroupFlagAndEnd(
// 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)) {
foundFlag = true; foundFlag = true;
state.nextFlag = { id: entry.id, expectedStart: null }; state.eventFlag = entry as PlayableEvent; // we know it is playable as it is coming from the playableEventOrder list
state._flag = { event: entry, isLinkedToLoaded, accumulatedGap }; state._flag = { event: entry, isLinkedToLoaded, accumulatedGap };
} }
} }
@@ -818,11 +798,6 @@ export function loadGroupFlagAndEnd(
foundGroupEnd = true; foundGroupEnd = true;
state._group = { event: lastEventInGroup, isLinkedToLoaded, accumulatedGap }; state._group = { event: lastEventInGroup, isLinkedToLoaded, accumulatedGap };
} }
if (!foundNextGroup && entry.parent !== currentGroupId) {
foundNextGroup = true;
state.groupNext = entry.parent;
}
} }
} }
@@ -832,26 +807,24 @@ export function loadGroupFlagAndEnd(
state._end = { event: lastEvent, isLinkedToLoaded, accumulatedGap }; state._end = { event: lastEvent, isLinkedToLoaded, accumulatedGap };
} }
if (!foundFlag) state.nextFlag = null; if (!foundFlag) state.eventFlag = null;
if (currentGroupId === null) { if ((state.groupNow?.id ?? null) !== currentGroupId) {
state.groupNow = null;
} else if ((state.groupNow != null && state.groupNow.id != currentGroupId) || state.groupNow == null) {
// we went into a new group - and it is different from the one we might have come from // we went into a new group - and it is different from the one we might have come from
// the id is set here, the start time is set when starting events state.offset.expectedGroupEnd = null;
state.groupNow = { id: currentGroupId, startedAt: null, expectedEnd: null };
} }
state.groupNow = currentGroupId ? (entries[currentGroupId] as OntimeGroup) : null;
} }
const resetMetaData = (state = runtimeState) => { const resetMetaData = (state = runtimeState) => {
state.groupNow = null; state.groupNow = null;
state.groupNext = null;
state._group = null; state._group = null;
state.nextFlag = null; state.eventFlag = null;
state._flag = null; state._flag = null;
state._end = null; state._end = null;
}; };
export function setOffsetMode(mode: OffsetMode) { export function setOffsetMode(mode: OffsetMode) {
runtimeState.runtime.offsetMode = mode; runtimeState.offset.mode = mode;
} }
@@ -1,13 +0,0 @@
import type { MaybeNumber } from '../../utils/utils.type.js';
import type { EntryId } from '../core/OntimeEntry.js';
export type GroupState = {
id: EntryId;
startedAt: MaybeNumber;
expectedEnd: MaybeNumber;
};
export type UpcomingEntry = {
id: EntryId;
expectedStart: MaybeNumber;
};
@@ -0,0 +1,15 @@
import type { MaybeNumber } from '../../utils/utils.type.js';
export enum OffsetMode {
Absolute = 'absolute',
Relative = 'relative',
}
export type Offset = {
absolute: number; // a positive value means that we are in over time aka behind schedule
relative: number;
mode: OffsetMode;
expectedGroupEnd: MaybeNumber;
expectedRundownEnd: MaybeNumber;
expectedFlagStart: MaybeNumber;
};
@@ -0,0 +1,9 @@
import type { MaybeNumber } from '../../utils/utils.type.js';
export type RundownState = {
selectedEventIndex: MaybeNumber;
numEvents: number;
plannedStart: MaybeNumber;
actualStart: MaybeNumber;
plannedEnd: MaybeNumber;
};
@@ -1,18 +0,0 @@
import type { MaybeNumber } from '../../utils/utils.type.js';
export enum OffsetMode {
Absolute = 'absolute',
Relative = 'relative',
}
export type Runtime = {
selectedEventIndex: MaybeNumber;
numEvents: number;
offsetAbs: number; // a positive value means that we are in over time aka behind schedule
offsetRel: number;
plannedStart: MaybeNumber;
actualStart: MaybeNumber;
plannedEnd: MaybeNumber;
expectedEnd: MaybeNumber;
offsetMode: OffsetMode;
};
@@ -1,6 +1,6 @@
import { SimpleDirection, SimplePlayback } from './AuxTimer.type.js'; import { SimpleDirection, SimplePlayback } from './AuxTimer.type.js';
import { OffsetMode } from './Offset.type.js';
import { Playback } from './Playback.type.js'; import { Playback } from './Playback.type.js';
import { OffsetMode } from './Runtime.type.js';
import type { RuntimeStore } from './RuntimeStore.type.js'; import type { RuntimeStore } from './RuntimeStore.type.js';
import { TimerPhase } from './TimerState.type.js'; import { TimerPhase } from './TimerState.type.js';
@@ -27,22 +27,25 @@ export const runtimeStorePlaceholder: Readonly<RuntimeStore> = {
}, },
secondary: '', secondary: '',
}, },
runtime: { rundown: {
selectedEventIndex: null, // changes if rundown changes or we load a new event selectedEventIndex: null, // changes if rundown changes or we load a new event
numEvents: 0, // change initiated by user numEvents: 0, // change initiated by user
offsetAbs: 0, // changes at runtime
offsetRel: 0, // changes at runtime
plannedStart: 0, // only changes if event changes plannedStart: 0, // only changes if event changes
plannedEnd: 0, // only changes if event changes, overflows over dayInMs plannedEnd: 0, // only changes if event changes, overflows over dayInMs
actualStart: null, // set once we start the timer actualStart: null, // set once we start the timer
expectedEnd: null, // changes with runtime, based on offset, overflows over dayInMs },
offsetMode: OffsetMode.Absolute, offset: {
absolute: 0, // changes at runtime
relative: 0, // changes at runtime
mode: OffsetMode.Absolute,
expectedFlagStart: null,
expectedGroupEnd: null,
expectedRundownEnd: null,
}, },
groupNow: null, groupNow: null,
groupNext: null,
nextFlag: null,
eventNow: null, eventNow: null,
eventNext: null, eventNext: null,
eventFlag: null,
auxtimer1: { auxtimer1: {
current: 0, current: 0,
direction: SimpleDirection.CountUp, direction: SimpleDirection.CountUp,
@@ -1,9 +1,8 @@
import type { MaybeString } from '../../utils/utils.type.js'; import type { OntimeEvent, OntimeGroup } from '../core/OntimeEntry.js';
import type { OntimeEvent } from '../core/OntimeEntry.js';
import type { SimpleTimerState } from './AuxTimer.type.js'; import type { SimpleTimerState } from './AuxTimer.type.js';
import type { GroupState, UpcomingEntry } from './CurrentGroupState.type.js';
import type { MessageState } from './MessageControl.type.js'; import type { MessageState } from './MessageControl.type.js';
import type { Runtime } from './Runtime.type.js'; import type { Offset } from './Offset.type.js';
import type { RundownState } from './RundownState.type.js';
import type { TimerState } from './TimerState.type.js'; import type { TimerState } from './TimerState.type.js';
export type RuntimeStore = { export type RuntimeStore = {
@@ -15,13 +14,16 @@ export type RuntimeStore = {
message: MessageState; message: MessageState;
// rundown data // rundown data
runtime: Runtime; rundown: RundownState;
// runtime
offset: Offset;
// relevant entries
eventNow: OntimeEvent | null; eventNow: OntimeEvent | null;
eventNext: OntimeEvent | null; eventNext: OntimeEvent | null;
eventFlag: OntimeEvent | null;
groupNow: GroupState | null; groupNow: OntimeGroup | null;
groupNext: MaybeString;
nextFlag: UpcomingEntry | null;
// extra timers // extra timers
auxtimer1: SimpleTimerState; auxtimer1: SimpleTimerState;
+3 -3
View File
@@ -96,12 +96,12 @@ export { Playback } from './definitions/runtime/Playback.type.js';
export { TimerLifeCycle, timerLifecycleValues } from './definitions/core/TimerLifecycle.type.js'; export { TimerLifeCycle, timerLifecycleValues } from './definitions/core/TimerLifecycle.type.js';
export type { TimerMessage, MessageState, SecondarySource } from './definitions/runtime/MessageControl.type.js'; export type { TimerMessage, MessageState, SecondarySource } from './definitions/runtime/MessageControl.type.js';
export type { Runtime } from './definitions/runtime/Runtime.type.js'; export type { RundownState } from './definitions/runtime/RundownState.type.js';
export { OffsetMode } from './definitions/runtime/Runtime.type.js'; export type { Offset } from './definitions/runtime/Offset.type.js';
export { OffsetMode } from './definitions/runtime/Offset.type.js';
export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js'; export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js';
export { runtimeStorePlaceholder } from './definitions/runtime/RuntimeStore.js'; export { runtimeStorePlaceholder } from './definitions/runtime/RuntimeStore.js';
export { type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js'; export { type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js';
export type { GroupState, UpcomingEntry } from './definitions/runtime/CurrentGroupState.type.js';
// ---> Extra Timer // ---> Extra Timer
export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './definitions/runtime/AuxTimer.type.js'; export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './definitions/runtime/AuxTimer.type.js';
@@ -15,7 +15,7 @@ describe('getExpectedStart()', () => {
currentDay: 0, currentDay: 0,
totalGap: 0, totalGap: 0,
offset: 0, offset: 0,
offsetMode: OffsetMode.Absolute, mode: OffsetMode.Absolute,
actualStart: null, actualStart: null,
plannedStart: null, plannedStart: null,
}; };
@@ -34,7 +34,7 @@ describe('getExpectedStart()', () => {
currentDay: 0, currentDay: 0,
totalGap: 0, totalGap: 0,
offset: 20, offset: 20,
offsetMode: OffsetMode.Absolute, mode: OffsetMode.Absolute,
actualStart: null, actualStart: null,
plannedStart: null, plannedStart: null,
}; };
@@ -53,7 +53,7 @@ describe('getExpectedStart()', () => {
currentDay: 0, currentDay: 0,
totalGap: 0, totalGap: 0,
offset: -10, offset: -10,
offsetMode: OffsetMode.Absolute, mode: OffsetMode.Absolute,
actualStart: null, actualStart: null,
plannedStart: null, plannedStart: null,
}; };
@@ -72,7 +72,7 @@ describe('getExpectedStart()', () => {
currentDay: 0, currentDay: 0,
totalGap: 20, totalGap: 20,
offset: 20, offset: 20,
offsetMode: OffsetMode.Absolute, mode: OffsetMode.Absolute,
actualStart: null, actualStart: null,
plannedStart: null, plannedStart: null,
}; };
@@ -91,7 +91,7 @@ describe('getExpectedStart()', () => {
currentDay: 0, currentDay: 0,
totalGap: 10, totalGap: 10,
offset: 20, offset: 20,
offsetMode: OffsetMode.Absolute, mode: OffsetMode.Absolute,
actualStart: 0, actualStart: 0,
plannedStart: 0, plannedStart: 0,
}; };
@@ -109,7 +109,7 @@ describe('getExpectedStart()', () => {
actualStart: 100, actualStart: 100,
plannedStart: 0, plannedStart: 0,
offset: 0, offset: 0,
offsetMode: OffsetMode.Relative, mode: OffsetMode.Relative,
}; };
const timeStartEvent2 = 10; const timeStartEvent2 = 10;
@@ -189,7 +189,7 @@ describe('getExpectedStart()', () => {
actualStart: 100, actualStart: 100,
plannedStart: 0, plannedStart: 0,
offset: 0, offset: 0,
offsetMode: OffsetMode.Relative, mode: OffsetMode.Relative,
}; };
expect(getExpectedStart(testEvent, { ...testState, isLinkedToLoaded: true })).toBe(120); expect(getExpectedStart(testEvent, { ...testState, isLinkedToLoaded: true })).toBe(120);
@@ -213,7 +213,7 @@ describe('getExpectedStart()', () => {
actualStart: 100, actualStart: 100,
plannedStart: 0, plannedStart: 0,
offset: 0, offset: 0,
offsetMode: OffsetMode.Relative, mode: OffsetMode.Relative,
}; };
expect(getExpectedStart(testEvent, { ...testState, isLinkedToLoaded: true })).toBe(120); expect(getExpectedStart(testEvent, { ...testState, isLinkedToLoaded: true })).toBe(120);
@@ -236,7 +236,7 @@ describe('getExpectedStart()', () => {
actualStart: 100, actualStart: 100,
plannedStart: 0, plannedStart: 0,
offset: 0, offset: 0,
offsetMode: OffsetMode.Relative, mode: OffsetMode.Relative,
}; };
// this event will start the current day // this event will start the current day
@@ -270,7 +270,7 @@ describe('getExpectedStart()', () => {
actualStart: 100, actualStart: 100,
plannedStart: 0, plannedStart: 0,
offset: 0, offset: 0,
offsetMode: OffsetMode.Relative, mode: OffsetMode.Relative,
isLinkedToLoaded: false, isLinkedToLoaded: false,
}; };
@@ -19,13 +19,13 @@ export function getExpectedStart(
totalGap: number; totalGap: number;
isLinkedToLoaded: boolean; isLinkedToLoaded: boolean;
offset: number; offset: number;
offsetMode: OffsetMode; mode: OffsetMode;
actualStart: MaybeNumber; actualStart: MaybeNumber;
plannedStart: MaybeNumber; plannedStart: MaybeNumber;
}, },
): number { ): number {
const { timeStart, dayOffset, delay } = event; const { timeStart, dayOffset, delay } = event;
const { currentDay, totalGap, isLinkedToLoaded, offset, offsetMode, actualStart, plannedStart } = state; const { currentDay, totalGap, isLinkedToLoaded, offset, mode, actualStart, plannedStart } = state;
//How many days from the currently running event to this one //How many days from the currently running event to this one
const relativeDayOffset = dayOffset - currentDay; const relativeDayOffset = dayOffset - currentDay;
@@ -37,7 +37,7 @@ export function getExpectedStart(
let relativeStartOffset = 0; let relativeStartOffset = 0;
if (offsetMode === OffsetMode.Relative) { if (mode === OffsetMode.Relative) {
relativeStartOffset = (actualStart ?? 0) - (plannedStart ?? 0); relativeStartOffset = (actualStart ?? 0) - (plannedStart ?? 0);
} }