block end calculation (#1683)

This commit is contained in:
Alex Christoffer Rasmussen
2025-07-13 17:01:21 +02:00
committed by Carlos Valente
parent e811a82be2
commit e9dda817e5
15 changed files with 474 additions and 376 deletions
@@ -174,6 +174,7 @@ export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) =
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
blockStartedAt: state.blockNow?.startedAt ?? null,
blockExpectedEnd: state.blockNow?.expectedEnd ?? null,
}));
export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
@@ -1,7 +1,4 @@
import { OffsetMode } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import { calculateTimeUntilStart, formatTime, nowInMillis } from '../time';
import { formatTime, nowInMillis } from '../time';
describe('nowInMillis()', () => {
it('should return the current time in milliseconds', () => {
@@ -41,257 +38,3 @@ describe('formatTime()', () => {
expect(time).toStrictEqual('-01:00');
});
});
describe('calculateTimeUntilStart()', () => {
describe('Absolute offset mode', () => {
test('ontime', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 90,
offset: 0,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(10);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
});
test('running behind', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 90,
offset: -20,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(30);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(30);
});
test('running ahead', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 80,
offset: 10,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // <-- when running ahead the unlinked timer stays put
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
});
test('running behind with enough gaps', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 20,
clock: 50,
offset: -20,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(50); // <-- when gap is enough to compensate for the running behind
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
});
test('running behind with too little gaps', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 10,
clock: 50,
offset: -20,
offsetMode: OffsetMode.Absolute,
actualStart: 0,
plannedStart: 0,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(60); // <-- when gap is not enough to compensate for the running behind it absorbs at much as possible
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
});
});
describe('Relative offset mode', () => {
test('basic function', () => {
const test = {
timeStart: 0,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
const timeStartEvent2 = 10;
const timeStartEvent3 = 20;
//event 1 is the currently running event
//event 2
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(10);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(10);
//event 3
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(20);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(20);
// When clock advances by 5ms, time until start should decrease by 5ms
test.clock = 105;
//event 2
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(5);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(5);
//event 3
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(15);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(15);
});
test('gaps', () => {
const test = {
timeStart: 20,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 10,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20);
// When clock advances by 5ms, time until start should decrease by 5ms
test.clock = 105;
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(15);
});
test('added/remove time', () => {
const test = {
timeStart: 20,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20);
test.offset = 5; // remove 5 with addtime - we are ahead of time
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // unlocked evets will stay on schedule
test.offset = -5; // add 5 with addtime - we are behind
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(25);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(25);
});
test('next day', () => {
const test = {
delay: 0,
currentDay: 0,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
// this event will start the current day
expect(
calculateTimeUntilStart({
...test,
timeStart: 10,
dayOffset: 0,
totalGap: 0,
isLinkedToLoaded: false,
}),
).toBe(10);
// this event will start the next day
// in absolute mode this would start in dayInMs - 100 since the gap would compensate
// but in relative mode with and actual start that is 100 offset it starts in dayInMs
expect(
calculateTimeUntilStart({
...test,
timeStart: 0,
dayOffset: 1,
totalGap: dayInMs - 20,
isLinkedToLoaded: false,
}),
).toBe(dayInMs);
// advancing 100ms
test.clock = 200;
expect(
calculateTimeUntilStart({
...test,
timeStart: 0,
dayOffset: 1,
totalGap: dayInMs - 20,
isLinkedToLoaded: false,
}),
).toBe(dayInMs - 100);
});
});
test('overlap with negative total gap', () => {
const test = {
dayOffset: 0,
delay: 0,
currentDay: 0,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
isLinkedToLoaded: false,
};
// the overlap will be pushed out to the expected available time
expect(calculateTimeUntilStart({ ...test, timeStart: 5, totalGap: -5 })).toBe(10);
test.clock = 105;
});
});
+8 -71
View File
@@ -1,5 +1,11 @@
import { MaybeNumber, OffsetMode, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
import { dayInMs, formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { MaybeNumber, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
import {
calculateTimeUntilStart,
formatFromMillis,
MILLIS_PER_HOUR,
MILLIS_PER_MINUTE,
MILLIS_PER_SECOND,
} from 'ontime-utils';
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
import { APP_SETTINGS } from '../api/constants';
@@ -143,72 +149,3 @@ export function useTimeUntilStart(
const { offset, clock, currentDay, offsetMode, actualStart, plannedStart } = useTimeUntilData();
return calculateTimeUntilStart({ ...data, currentDay, clock, offset, offsetMode, actualStart, plannedStart });
}
/**
*
* @param currentDay the day offset of the urrently 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
*/
export function calculateTimeUntilStart(
data: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'> & {
currentDay: number;
totalGap: number;
isLinkedToLoaded: boolean;
clock: number;
offset: number;
offsetMode: OffsetMode;
actualStart: MaybeNumber;
plannedStart: MaybeNumber;
},
): number {
const {
timeStart,
dayOffset,
currentDay,
totalGap,
isLinkedToLoaded,
clock,
offset,
delay,
offsetMode,
actualStart,
plannedStart,
} = data;
//How many days from the currently running event to this one
const relativeDayOffset = dayOffset - currentDay;
const delayedStart = Math.max(0, timeStart + delay);
//The normalised start time of this event relative to the currently running event
const normalisedTimeStart = delayedStart + relativeDayOffset * dayInMs;
let relativeStartOffset = 0;
if (offsetMode === OffsetMode.Relative) {
relativeStartOffset = (actualStart ?? 0) - (plannedStart ?? 0);
}
const scheduledTimeUntil = normalisedTimeStart - clock + relativeStartOffset;
const offsetTimeUntil = scheduledTimeUntil - offset;
if (isLinkedToLoaded) {
//if we are directly linked back to the loaded event we just follow the offset
return offsetTimeUntil;
}
const gapsCanCompensateForOffset = totalGap + offset >= 0;
if (gapsCanCompensateForOffset) {
// if we are ahead of schedule or the gap can compensate for the amount we are behind then expect to start at the scheduled time
return scheduledTimeUntil;
}
// otherwise consume as much of the offset as possible with the gap
const offsetTimeUntilBufferedByGaps = offsetTimeUntil - totalGap;
return offsetTimeUntilBufferedByGaps;
}
@@ -1,8 +1,9 @@
import { PropsWithChildren, ReactNode } from 'react';
import { ErrorBoundary } from '@sentry/react';
import { isOntimeBlock } from 'ontime-types';
import { isOntimeBlock, TimerType } from 'ontime-types';
import { isPlaybackActive, millisToString } from 'ontime-utils';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import {
useClock,
useCurrentBlockId,
@@ -12,7 +13,7 @@ import {
} from '../../../common/hooks/useSocket';
import useProjectData from '../../../common/hooks-query/useProjectData';
import { useEntry } from '../../../common/hooks-query/useRundown';
import { cx, enDash, timerPlaceholder, timerPlaceholderMin } from '../../../common/utils/styleUtils';
import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
import { formatedTime, getOffsetText } from '../overviewUtils';
import { TimeColumn, TimeRow } from './TimeLayout';
@@ -51,46 +52,61 @@ export function TitlesOverview() {
}
export function CurrentBlockOverview() {
const { blockStartedAt: blockStartAt, clock } = useRuntimePlaybackOverview();
const { blockStartedAt, clock, blockExpectedEnd } = useRuntimePlaybackOverview();
const { currentBlockId } = useCurrentBlockId();
const entry = useEntry(currentBlockId);
const timeInBlock = formatedTime(blockStartAt ? clock - blockStartAt : null, 2);
const timeInBlock = formatedTime(blockStartedAt ? clock - blockStartedAt : null, 3, TimerType.CountUp);
const blockExpectedEndString = formatedTime(blockExpectedEnd, 3, TimerType.CountUp);
/**
* The time to the end of the block
* as scheduled
* TODO(v4): this needs to be calculated according to offset mode
*/
const blockEnd = (() => {
if (!entry) return timerPlaceholderMin;
if (!isOntimeBlock(entry)) return timerPlaceholderMin;
if (entry.timeEnd === null) return timerPlaceholderMin;
return formatedTime(entry.timeEnd - clock, 2);
const remainingBlockDuration = (() => {
if (blockStartedAt === null || !entry) return timerPlaceholder;
if (!isOntimeBlock(entry)) return timerPlaceholder;
return formatedTime(blockStartedAt + entry.duration - clock, 3, TimerType.CountDown);
})();
/**
* The time to the end of the block
* as projected accounting for delays and offset
* TODO(v4): this needs to be calculated according to offset mode
*/
const projectedBlockEnd = (() => {
if (blockStartAt === null || !entry) return timerPlaceholderMin;
if (!isOntimeBlock(entry)) return timerPlaceholderMin;
return formatedTime(blockStartAt + entry.duration - clock, 2);
})();
const timeUntilBlockEnd = (() => {
if (blockExpectedEnd === null) return timerPlaceholder;
return formatedTime(blockExpectedEnd - clock, 3, TimerType.CountDown);
})() ;
return (
<>
<TimeColumn label='Elapsed in group' value={timeInBlock} className={style.clock} muted={blockStartAt === null} />
<div>
<TimeRow label='Group end' value={blockEnd} className={style.end} muted={blockStartAt === null} />
<TimeRow
label='Projected group end'
value={projectedBlockEnd}
className={style.end}
muted={blockStartAt === null}
/>
<Tooltip text='How long the group has been active'>
<TimeRow
label='Elapsed in group'
value={timeInBlock}
className={style.clock}
muted={blockStartedAt === null}
/>
</Tooltip>
<Tooltip text='Remaining time until the planed group duration is up'>
<TimeRow
label='Remaining group duration'
value={remainingBlockDuration}
className={style.clock}
muted={blockStartedAt === null}
/>
</Tooltip>
</div>
<div>
<Tooltip text='Expected time until the group can end, if everything ends on time from now on'>
<TimeRow
label='Expected time until group end'
value={timeUntilBlockEnd}
className={style.end}
muted={blockStartedAt === null}
/>
</Tooltip>
<Tooltip text='Expected time the group will end, if everything ends on time from now on'>
<TimeRow
label='Expected group end'
value={blockExpectedEndString}
className={style.end}
muted={blockStartedAt === null}
/>
</Tooltip>
</div>
</>
);
@@ -1,7 +1,7 @@
.label {
color: $label-gray;
font-size: calc(1rem - 2px);
width: 10em; // a number large enough to force right alignment
width: 15em; // a number large enough to force right alignment
}
.clock {
@@ -1,4 +1,4 @@
import { MaybeNumber } from 'ontime-types';
import { MaybeNumber, TimerType } from 'ontime-types';
import { dayInMs, millisToString } from 'ontime-utils';
import { enDash, timerPlaceholder, timerPlaceholderMin } from '../../common/utils/styleUtils';
@@ -6,8 +6,12 @@ import { enDash, timerPlaceholder, timerPlaceholderMin } from '../../common/util
/**
* Encapsulates the logic for formatting time in overview
*/
export function formatedTime(time: MaybeNumber, segments: number = 3): string {
return millisToString(time, { fallback: segments === 3 ? timerPlaceholder : timerPlaceholderMin });
export function formatedTime(
time: MaybeNumber,
segments: number = 3,
direction?: TimerType.CountDown | TimerType.CountUp,
): string {
return millisToString(time, { fallback: segments === 3 ? timerPlaceholder : timerPlaceholderMin, direction });
}
/**
@@ -771,11 +771,12 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
!deepEqual(RuntimeService.previousState?.runtime, state.runtime);
/**
* the currentBlock object has no ticking values so we only need to check for equality
* the currentBlock object has the potential to tick on expected end
* TODO: the value shows up one tick to late
*/
const shouldBlockUpdate =
!deepEqual(RuntimeService?.previousState.blockNow, state.blockNow) ||
!deepEqual(RuntimeService?.previousState.blockNext, state.blockNext);
RuntimeService?.previousState.blockNext !== state.blockNext;
/**
* Many other values are calculated based on the clock
@@ -806,7 +807,7 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
batch.add('blockNow', state.blockNow);
batch.add('blockNext', state.blockNext);
RuntimeService.previousState.blockNow = structuredClone(state.blockNow);
RuntimeService.previousState.blockNext = structuredClone(state.blockNext);
RuntimeService.previousState.blockNext = state.blockNext;
}
if (hasImmediateChanges) {
+55 -2
View File
@@ -1,12 +1,65 @@
import { MaybeNumber, TimerPhase } from 'ontime-types';
import { dayInMs, isPlaybackActive } from 'ontime-utils';
import { isOntimeEvent, MaybeNumber, OntimeBlock, OntimeEvent, Rundown, TimerPhase } from 'ontime-types';
import { calculateTimeUntilStart, dayInMs, isPlaybackActive } from 'ontime-utils';
import type { RuntimeState } from '../stores/runtimeState.js';
import { shouldCrashDev } from '../utils/development.js';
/**
* handle events that span over midnight
*/
export const normaliseEndTime = (start: number, end: number) => (end < start ? end + dayInMs : end);
/**
* Calculates the expected time of the group to end.
* Should only be called if a block is running
* TODO: take a look at how it handles relative offset mode
*/
export function getExpectedBlockFinish(state: RuntimeState, rundown: Rundown): MaybeNumber {
const { blockNow, eventNow, timer, clock } = state;
if (blockNow === null) return null;
// if the group doesn't have a start time there is no end time either
if (blockNow.startedAt === null) return null;
if (eventNow === null) return null;
if (timer.current === null) return null;
const { entries } = rundown;
const orderInBlock = (entries[blockNow.id] as OntimeBlock).entries;
const indexInBlock = orderInBlock.findIndex((id) => eventNow.id === id);
shouldCrashDev(indexInBlock < 0, 'Running event is not in current block');
if (indexInBlock === orderInBlock.length - 1) return timer.expectedFinish;
let totalGap = 0;
let isLinkedToLoaded = true;
for (let i = indexInBlock + 1; i < orderInBlock.length; i++) {
const entry = entries[orderInBlock[i]];
if (isOntimeEvent(entry)) {
totalGap += entry.gap;
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart;
}
}
const lastEntry = entries[orderInBlock.at(-1)!] as OntimeEvent;
const { offsetMode, offset, plannedStart, actualStart } = state.runtime;
const timeUntilLastEvent = calculateTimeUntilStart({
timeStart: lastEntry.timeStart,
dayOffset: lastEntry.dayOffset,
delay: lastEntry.delay,
currentDay: eventNow.dayOffset,
totalGap,
isLinkedToLoaded,
clock,
offsetMode,
offset,
plannedStart,
actualStart,
});
return clock + timeUntilLastEvent + lastEntry.duration;
}
/**
* Calculates expected finish time of a running timer
* @param {RuntimeState} state runtime state
+12 -3
View File
@@ -18,6 +18,7 @@ import { timeNow } from '../utils/time.js';
import type { RestorePoint } from '../services/RestoreService.js';
import {
getCurrent,
getExpectedBlockFinish,
getExpectedEnd,
getExpectedFinish,
getRuntimeOffset,
@@ -27,11 +28,12 @@ import { loadRoll, normaliseRollStart } from '../services/rollUtils.js';
import { timerConfig } from '../setup/config.js';
import { RundownMetadata } from '../api-data/rundown/rundown.types.js';
import { getPlayableIndexFromTimedIndex } from '../api-data/rundown/rundown.utils.js';
import { getCurrentRundown } from '../api-data/rundown/rundown.dao.js';
export type RuntimeState = {
clock: number; // realtime clock
blockNow: BlockState | null;
blockNext: BlockState | null;
blockNext: MaybeString;
eventNow: PlayableEvent | null;
eventNext: PlayableEvent | null;
runtime: Runtime;
@@ -90,6 +92,8 @@ export function clearEventData() {
runtimeState.runtime.expectedEnd = null;
runtimeState.runtime.selectedEventIndex = null;
if (runtimeState.blockNow) runtimeState.blockNow.expectedEnd = null;
runtimeState.timer.playback = Playback.Stop;
runtimeState.clock = timeNow();
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
@@ -509,6 +513,11 @@ export function update(): UpdateResult {
runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState);
}
if (runtimeState.blockNow) {
const expectedBlockFinish = getExpectedBlockFinish(runtimeState, getCurrentRundown());
runtimeState.blockNow.expectedEnd = expectedBlockFinish;
}
return { hasTimerFinished: finishedNow, hasSecondaryTimerFinished: false };
function updateIfIdle() {
@@ -690,7 +699,7 @@ export function loadBlock(rundown: Rundown, state = runtimeState) {
let foundEventNow = false;
for (const id of rundown.order) {
if (foundEventNow && isOntimeBlock(rundown.entries[id])) {
state.blockNext = { id, startedAt: null }; // the id is set here, the start time is set in other placed that handel starting events
state.blockNext = id; // the id is set here, the start time is set in other placed that handel starting events
break;
}
if (id === state.eventNow.id) {
@@ -707,7 +716,7 @@ export function loadBlock(rundown: Rundown, state = runtimeState) {
//we went into a new block - and it is different from the one we might have come from
if ((state.blockNow != null && state.blockNow.id != currentBlockId) || state.blockNow == null) {
state.blockNow = { id: currentBlockId, startedAt: null }; // the id is set here, the start time is set in other placed that handel starting events
state.blockNow = { id: currentBlockId, startedAt: null, expectedEnd: null }; // the id is set here, the start time is set in other placed that handel starting events
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ test('CRUD operations on the rundown', async ({ page }) => {
await expect(page.getByTestId('rundown-block')).toHaveCount(0);
// create groups using the quick add buttons
await page.getByRole('button', { name: 'Group' }).nth(1).click();
await page.getByTestId('rundown').getByRole('button', { name: 'Group' }).nth(1).click();
await page.getByRole('button', { name: 'Delay' }).nth(1).click();
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(2);
@@ -4,4 +4,5 @@ import type { EntryId } from '../core/OntimeEntry.js';
export type BlockState = {
id: EntryId;
startedAt: MaybeNumber;
expectedEnd: MaybeNumber;
};
@@ -1,3 +1,4 @@
import type { MaybeString } from '../../utils/utils.type.js';
import type { OntimeEvent } from '../core/OntimeEntry.js';
import type { SimpleTimerState } from './AuxTimer.type.js';
import type { BlockState } from './CurrentBlockState.type.js';
@@ -18,9 +19,9 @@ export type RuntimeStore = {
runtime: Runtime;
eventNow: OntimeEvent | null;
eventNext: OntimeEvent | null;
blockNow: BlockState | null;
blockNext: BlockState | null;
blockNext: MaybeString;
// extra timers
auxtimer1: SimpleTimerState;
+2
View File
@@ -74,6 +74,8 @@ export { validateEndAction, validateTimerType } from './src/validate-events/vali
// feature business logic
export { calculateTimeUntilStart } from './src/date-utils/calculateTimeUntilStart.js';
// feature business logic - rundown
export { checkIsNow } from './src/date-utils/checkIsNow.js';
export { checkIsNextDay } from './src/date-utils/checkIsNextDay.js';
@@ -0,0 +1,258 @@
import { OffsetMode } from 'ontime-types';
import { calculateTimeUntilStart } from './calculateTimeUntilStart';
import { dayInMs } from './conversionUtils';
describe('calculateTimeUntilStart()', () => {
describe('Absolute offset mode', () => {
test('ontime', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 90,
offset: 0,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(10);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
});
test('running behind', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 90,
offset: -20,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(30);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(30);
});
test('running ahead', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 80,
offset: 10,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // <-- when running ahead the unlinked timer stays put
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
});
test('running behind with enough gaps', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 20,
clock: 50,
offset: -20,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(50); // <-- when gap is enough to compensate for the running behind
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
});
test('running behind with too little gaps', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 10,
clock: 50,
offset: -20,
offsetMode: OffsetMode.Absolute,
actualStart: 0,
plannedStart: 0,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(60); // <-- when gap is not enough to compensate for the running behind it absorbs at much as possible
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
});
});
describe('Relative offset mode', () => {
test('basic function', () => {
const test = {
timeStart: 0,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
const timeStartEvent2 = 10;
const timeStartEvent3 = 20;
//event 1 is the currently running event
//event 2
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(10);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(10);
//event 3
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(20);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(20);
// When clock advances by 5ms, time until start should decrease by 5ms
test.clock = 105;
//event 2
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(5);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(5);
//event 3
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(15);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(15);
});
test('gaps', () => {
const test = {
timeStart: 20,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 10,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20);
// When clock advances by 5ms, time until start should decrease by 5ms
test.clock = 105;
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(15);
});
test('added/remove time', () => {
const test = {
timeStart: 20,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20);
test.offset = 5; // remove 5 with addtime - we are ahead of time
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // unlocked evets will stay on schedule
test.offset = -5; // add 5 with addtime - we are behind
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(25);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(25);
});
test('next day', () => {
const test = {
delay: 0,
currentDay: 0,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
// this event will start the current day
expect(
calculateTimeUntilStart({
...test,
timeStart: 10,
dayOffset: 0,
totalGap: 0,
isLinkedToLoaded: false,
}),
).toBe(10);
// this event will start the next day
// in absolute mode this would start in dayInMs - 100 since the gap would compensate
// but in relative mode with and actual start that is 100 offset it starts in dayInMs
expect(
calculateTimeUntilStart({
...test,
timeStart: 0,
dayOffset: 1,
totalGap: dayInMs - 20,
isLinkedToLoaded: false,
}),
).toBe(dayInMs);
// advancing 100ms
test.clock = 200;
expect(
calculateTimeUntilStart({
...test,
timeStart: 0,
dayOffset: 1,
totalGap: dayInMs - 20,
isLinkedToLoaded: false,
}),
).toBe(dayInMs - 100);
});
});
test('overlap with negative total gap', () => {
const test = {
dayOffset: 0,
delay: 0,
currentDay: 0,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
isLinkedToLoaded: false,
};
// the overlap will be pushed out to the expected available time
expect(calculateTimeUntilStart({ ...test, timeStart: 5, totalGap: -5 })).toBe(10);
test.clock = 105;
});
});
@@ -0,0 +1,72 @@
import type { MaybeNumber, OntimeEvent } from 'ontime-types';
import { OffsetMode } from 'ontime-types';
import { dayInMs } from './conversionUtils.js';
/**
* @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
*/
export function calculateTimeUntilStart(
data: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'> & {
currentDay: number;
totalGap: number;
isLinkedToLoaded: boolean;
clock: number;
offset: number;
offsetMode: OffsetMode;
actualStart: MaybeNumber;
plannedStart: MaybeNumber;
},
): number {
const {
timeStart,
dayOffset,
currentDay,
totalGap,
isLinkedToLoaded,
clock,
offset,
delay,
offsetMode,
actualStart,
plannedStart,
} = data;
//How many days from the currently running event to this one
const relativeDayOffset = dayOffset - currentDay;
const delayedStart = Math.max(0, timeStart + delay);
//The normalised start time of this event relative to the currently running event
const normalisedTimeStart = delayedStart + relativeDayOffset * dayInMs;
let relativeStartOffset = 0;
if (offsetMode === OffsetMode.Relative) {
relativeStartOffset = (actualStart ?? 0) - (plannedStart ?? 0);
}
const scheduledTimeUntil = normalisedTimeStart - clock + relativeStartOffset;
const offsetTimeUntil = scheduledTimeUntil - offset;
if (isLinkedToLoaded) {
//if we are directly linked back to the loaded event we just follow the offset
return offsetTimeUntil;
}
const gapsCanCompensateForOffset = totalGap + offset >= 0;
if (gapsCanCompensateForOffset) {
// if we are ahead of schedule or the gap can compensate for the amount we are behind then expect to start at the scheduled time
return scheduledTimeUntil;
}
// otherwise consume as much of the offset as possible with the gap
const offsetTimeUntilBufferedByGaps = offsetTimeUntil - totalGap;
return offsetTimeUntilBufferedByGaps;
}