Compare commits

...

5 Commits

Author SHA1 Message Date
Carlos Valente 8aa22ebc09 refactor: finish of count-to-end is plan or start time 2026-07-17 22:24:59 +02:00
Carlos Valente 41d3031a39 refactor(rundown): improve visibility of count to end 2026-07-17 11:04:36 +02:00
Carlos Valente 73a61887a6 refactor(time control): improve handling of overtime and count to end 2026-07-17 11:01:40 +02:00
Carlos Valente e1f6b22d19 fix: delays account for midnight 2026-07-14 14:31:08 +02:00
Carlos Valente 0ff226f0ec fix: count-to-end events break link chain 2026-07-14 14:31:08 +02:00
22 changed files with 673 additions and 177 deletions
+11
View File
@@ -139,6 +139,17 @@ export const setEventPlayback = {
pause: () => sendSocket('pause', undefined),
};
export const useTimerProgress = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback,
phase: state.timer.phase,
addedTime: state.timer.addedTime,
secondaryTimer: state.timer.secondaryTimer,
current: state.timer.current,
expectedFinish: state.timer.expectedFinish,
startedAt: state.timer.startedAt,
isCountToEnd: state.eventNow?.countToEnd ?? false,
}));
export const useTimer = createSelector((state: RuntimeStore) => ({
...state.timer,
}));
+16 -10
View File
@@ -4,6 +4,7 @@ import {
MILLIS_PER_MINUTE,
MILLIS_PER_SECOND,
formatFromMillis,
getExpectedEnd,
getExpectedStart,
} from 'ontime-utils';
@@ -172,23 +173,28 @@ export function getExpectedTimesFromExtendedEvent(
) {
if (event === null) return { expectedStart: 0, timeToStart: 0, expectedEnd: 0, plannedEnd: 0 };
const expectedStartState = {
totalGap: event.totalGap,
isLinkedToLoaded: event.isLinkedToLoaded,
...state,
};
const expectedStart = getExpectedStart(
{ timeStart: event.timeStart, delay: event.delay, dayOffset: event.dayOffset },
{
totalGap: event.totalGap,
isLinkedToLoaded: event.isLinkedToLoaded,
...state,
},
expectedStartState,
);
const plannedEnd = event.timeStart + event.duration + event.delay;
// count to end events are fixed to the scheduled end and ignore delays
const delayToAdd = event.countToEnd ? 0 : event.delay;
const plannedEnd = event.timeStart + event.duration + delayToAdd;
// we let timeToStart go negative to allow the UI to show due timers
const timeToStart = expectedStart - state.clock;
return {
expectedStart,
timeToStart: expectedStart - state.clock,
expectedEnd: event.countToEnd
? Math.max(expectedStart + event.duration, plannedEnd)
: expectedStart + event.duration,
timeToStart,
expectedEnd: getExpectedEnd(event, expectedStartState),
plannedEnd,
};
}
@@ -73,6 +73,10 @@
margin-right: 0.25rem;
}
.tagOvertime {
color: $playback-over;
}
.time {
color: $section-white;
font-size: $text-body-size;
@@ -1,11 +1,12 @@
import { MaybeNumber, Playback, TimerPhase } from 'ontime-types';
import { dayInMs, millisToString } from 'ontime-utils';
import { millisToString } from 'ontime-utils';
import { PropsWithChildren } from 'react';
import AppLink from '../../../../common/components/link/app-link/AppLink';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
import useReport from '../../../../common/hooks-query/useReport';
import { useTimer } from '../../../../common/hooks/useSocket';
import { useTimerProgress } from '../../../../common/hooks/useSocket';
import { cx } from '../../../../common/utils/styleUtils';
import { formatDuration } from '../../../../common/utils/time';
import TimerDisplay from '../timer-display/TimerDisplay';
@@ -24,15 +25,14 @@ function resolveAddedTimeLabel(addedTime: number) {
}
export default function PlaybackTimer({ children }: PropsWithChildren) {
const timer = useTimer();
'use memo';
const timer = useTimerProgress();
const isRolling = timer.playback === Playback.Roll;
const isWaiting = timer.phase === TimerPhase.Pending;
const isOvertime = timer.phase === TimerPhase.Overtime;
const hasAddedTime = Boolean(timer.addedTime);
const rollLabel = isRolling ? 'Roll mode active' : '';
const addedTimeLabel = resolveAddedTimeLabel(timer.addedTime);
return (
@@ -51,7 +51,13 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
{isWaiting ? (
<span className={style.rolltag}>Roll: Countdown to start</span>
) : (
<RunningStatus startedAt={timer.startedAt} expectedFinish={timer.expectedFinish} playback={timer.playback} />
<RunningStatus
startedAt={timer.startedAt}
expectedFinish={timer.expectedFinish}
isStopped={timer.playback === Playback.Stop}
isCountToEnd={timer.isCountToEnd}
isOvertime={isOvertime}
/>
)}
</div>
{children}
@@ -62,16 +68,18 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
interface RunningStatusProps {
startedAt: MaybeNumber;
expectedFinish: MaybeNumber;
playback: Playback;
isStopped: boolean;
isCountToEnd: boolean;
isOvertime: boolean;
}
function RunningStatus({ startedAt, expectedFinish, playback }: RunningStatusProps) {
if (playback === Playback.Stop) {
function RunningStatus({ startedAt, expectedFinish, isStopped, isCountToEnd, isOvertime }: RunningStatusProps) {
if (isStopped) {
return <StoppedStatus />;
}
const started = millisToString(startedAt);
const finishedMs = expectedFinish !== null ? expectedFinish % dayInMs : null;
const finish = millisToString(finishedMs);
const finish = millisToString(expectedFinish);
return (
<>
@@ -80,7 +88,9 @@ function RunningStatus({ startedAt, expectedFinish, playback }: RunningStatusPro
<span className={style.time}>{started}</span>
</span>
<span className={style.finish}>
<span className={style.tag}>Expect end</span>
<span className={cx([style.tag, isOvertime && style.tagOvertime])}>
{isCountToEnd ? 'Scheduled end' : 'Expected end'}
</span>
<span className={style.time}>{finish}</span>
</span>
</>
@@ -7,6 +7,7 @@ $skip-opacity: 0.2;
background-color: $block-bg;
margin-block: 0.25rem;
overflow: initial;
position: relative;
display: grid;
grid-template-areas:
@@ -35,6 +36,10 @@ $skip-opacity: 0.2;
background-color: $gray-1325;
}
&.countToEnd {
--status-color-active-override: #{$orange-400};
}
&.play {
background-color: $active-green;
@include declare-overrides;
@@ -155,7 +160,7 @@ $skip-opacity: 0.2;
align-items: center;
justify-content: space-between;
.nextTag {
.warningMeta {
font-size: 1rem;
color: $orange-500;
letter-spacing: 0.03px;
@@ -253,6 +253,7 @@ export default function RundownEvent({
const blockClasses = cx([
style.rundownEvent,
countToEnd && style.countToEnd,
skip && style.skip,
isPast && style.past,
loaded && style.loaded,
@@ -120,7 +120,8 @@ function RundownEventInner({
</div>
<div className={style.titleSection}>
<TitleEditor title={title} entryId={eventId} placeholder='Event title' className={style.eventTitle} />
{isNext && <span className={style.nextTag}>UP NEXT</span>}
{isNext && <span className={style.warningMeta}>UP NEXT</span>}
{!isNext && countToEnd && <span className={style.warningMeta}>COUNT TO END</span>}
</div>
<EventBlockPlayback
eventId={eventId}
@@ -27,7 +27,6 @@ interface TimeInputFlowProps {
export default memo(TimeInputFlow);
function TimeInputFlow({
eventId,
countToEnd,
timeStart,
timeEnd,
duration,
@@ -56,10 +55,6 @@ function TimeInputFlow({
warnings.push('Over midnight');
}
if (countToEnd) {
warnings.push('Count to End');
}
const hasDelay = delay !== 0;
const isLockedEnd = timeStrategy === TimeStrategy.LockEnd;
const isLockedDuration = timeStrategy === TimeStrategy.LockDuration;
@@ -151,9 +151,8 @@ type ScheduleTimeProps = {
showExpected: boolean;
};
//TODO: consider relative mode
export function ScheduleTime(props: ScheduleTimeProps) {
const { event, showExpected } = props;
const { timeStart, duration, delay, expectedStart, countToEnd } = event;
export function ScheduleTime({ event, showExpected }: ScheduleTimeProps) {
const { timeStart, duration, delay, expectedStart, expectedEnd, countToEnd } = event;
const plannedStart = timeStart + delay + event.dayOffset * dayInMs;
@@ -163,8 +162,14 @@ export function ScheduleTime(props: ScheduleTimeProps) {
const plannedStateClass = isExpectedValueShow ? 'sub__schedule--strike' : delay !== 0 ? 'sub__schedule--delayed' : '';
const expectedStateClass = `sub__schedule--${getOffsetState(expectedStart - plannedStart)}`;
const plannedEnd = plannedStart + duration + delay;
const expectedEnd = countToEnd ? Math.max(expectedStart + duration, plannedEnd) : expectedStart + duration;
// count to end events are fixed to the scheduled end and ignore delays
const plannedEnd = (() => {
if (countToEnd) {
return timeStart + event.dayOffset * dayInMs + duration;
}
return plannedStart + duration;
})();
const expectedEndClass = `sub__schedule--${getOffsetState(expectedEnd - plannedEnd)}`;
return (
@@ -1,5 +1,4 @@
import { MaybeNumber, OntimeEvent } from 'ontime-types';
import { getExpectedStart } from 'ontime-utils';
import { IoPencil } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button';
@@ -11,7 +10,7 @@ import { cx } from '../../common/utils/styleUtils';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getPropertyValue } from '../common/viewUtils';
import { useCountdownOptions } from './countdown.options';
import { CountdownTarget, useSubscriptionDisplayData } from './countdown.utils';
import { CountdownTarget, extendEventData, useSubscriptionDisplayData } from './countdown.utils';
import { ScheduleTime } from './CountdownSubscriptions';
import './SingleEventCountdown.scss';
@@ -27,19 +26,15 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
const { data: reportData } = useReport();
const { offset, currentDay, actualStart, plannedStart, mode } = useExpectedStartData();
const { totalGap, isLinkedToLoaded } = subscribedEvent;
const expectedStart = getExpectedStart(subscribedEvent, {
const countdownEvent = extendEventData(
subscribedEvent,
currentDay,
totalGap,
actualStart,
plannedStart,
isLinkedToLoaded,
offset,
mode,
});
const { endedAt } = reportData[subscribedEvent.reportId ?? subscribedEvent.id] ?? { endedAt: null };
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt };
reportData,
);
const titleTmp = getPropertyValue(subscribedEvent, mainSource ?? 'title');
const title = titleTmp?.length ? titleTmp : ' '; // insert utf-8 empty space to avoid the line collapsing;
// while a group is live, surface the running event's title as the secondary line
@@ -12,7 +12,7 @@ import {
isOntimeGroup,
isPlayableEvent,
} from 'ontime-types';
import { MILLIS_PER_MINUTE, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
import { MILLIS_PER_MINUTE, getExpectedEnd, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
import { useCountdownSocket } from '../../common/hooks/useSocket';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
@@ -197,7 +197,11 @@ export type CountdownTarget = ExtendedEntry<OntimeEvent> & {
liveEntry?: ExtendedEntry<OntimeEvent> | null; // the running child while a group is live
};
export type CountdownEvent = CountdownTarget & { expectedStart: number; endedAt: MaybeNumber };
export type CountdownEvent = CountdownTarget & {
expectedStart: number;
expectedEnd: number;
endedAt: MaybeNumber;
};
/**
* Resolves a subscription (event or group) into an event-shaped countdown target.
@@ -262,7 +266,7 @@ export function extendEventData(
reportData: OntimeReport,
): CountdownEvent {
const { totalGap, isLinkedToLoaded } = event;
const expectedStart = getExpectedStart(event, {
const expectedStartState = {
currentDay,
totalGap,
actualStart,
@@ -270,7 +274,9 @@ export function extendEventData(
isLinkedToLoaded,
offset,
mode,
});
};
const expectedStart = getExpectedStart(event, expectedStartState);
const expectedEnd = getExpectedEnd(event, expectedStartState);
const { endedAt } = reportData[event.reportId ?? event.id] ?? { endedAt: null };
return { ...event, expectedStart, endedAt };
return { ...event, expectedStart, expectedEnd, endedAt };
}
+1 -1
View File
@@ -42,7 +42,7 @@
"scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
"postinstall": "pnpm addversion",
"dev": "cross-env NODE_ENV=development tsx watch --tsconfig tsconfig.app.json ./src/index.ts",
"dev": "cross-env PORT=4001 NODE_ENV=development tsx watch --tsconfig tsconfig.app.json ./src/index.ts",
"dev:electron": "pnpm dev",
"dev:inspect": "cross-env NODE_ENV=development tsx watch --tsconfig tsconfig.app.json --inspect ./src/index.ts",
"lint": "oxlint --quiet --type-aware",
@@ -7,11 +7,21 @@ import { initRundown } from '../../api-data/rundown/rundown.service.js';
import { loadRoll } from '../rollUtils.js';
vi.mock('../../classes/data-provider/DataProvider.js', () => {
let automation = {
enabledAutomations: false,
enabledOscIn: false,
oscPortIn: 8888,
triggers: [],
automations: {},
};
return {
getDataProvider: vi.fn().mockImplementation(() => {
return {
getAutomation: vi.fn().mockImplementation(() => automation),
setCustomFields: vi.fn().mockImplementation((newData) => newData),
setRundown: vi.fn().mockImplementation((newData) => newData),
setAutomation: vi.fn().mockImplementation((newData) => (automation = newData)),
};
}),
};
@@ -225,7 +225,7 @@ describe('getExpectedFinish()', () => {
expect(calculatedFinish).toBe(10);
});
describe('on timers of type time-to-end', () => {
it('finish time is as schedule + added time', () => {
it('finish time is the fixed end, ignoring added time', () => {
const state = {
eventNow: {
timeEnd: 30,
@@ -242,32 +242,57 @@ describe('getExpectedFinish()', () => {
},
} as RuntimeState;
// the end is anchored: added time surfaces as offset, it does not move the finish
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(40);
expect(calculatedFinish).toBe(30);
});
it('handles events that finish the day after', () => {
it('finish time is the fixed end, ignoring pauses', () => {
const state = {
eventNow: {
timeEnd: 600000, // 00:10:00
timeEnd: 30,
countToEnd: true,
},
clock: 25,
timer: {
addedTime: 0,
duration: dayInMs,
startedAt: 10,
},
_timer: {
pausedAt: 20, // paused 5 ago - wall clock keeps approaching the fixed end
hasFinished: false,
},
} as RuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(30);
});
it('returns the start time for count to end times which started late', () => {
const state = {
eventNow: {
timeStart: 20 * MILLIS_PER_HOUR, // 20:00:00
timeEnd: 21 * MILLIS_PER_MINUTE, // 21:00:00
countToEnd: true,
},
timer: {
addedTime: 0,
startedAt: 79200000, // 22:00:00
startedAt: 22 * MILLIS_PER_HOUR, // 22:00:00 <--------------
},
_timer: {
pausedAt: null,
hasFinished: false,
},
rundown: {
actualStart: 79200000,
plannedEnd: 600000,
actualStart: 20 * MILLIS_PER_HOUR, // 20:00:00
plannedEnd: 21 * MILLIS_PER_HOUR, // 21:00:00
},
} as RuntimeState;
const calculatedFinish = getExpectedFinish(state);
// expected finish is not a duration but a point in time
expect(calculatedFinish).toBe(600000);
// timeEnd is numerically before startedAt for overnight events, so expectedFinish is clamped to startedAt
expect(calculatedFinish).toBe(22 * MILLIS_PER_HOUR);
});
});
});
@@ -451,7 +476,7 @@ describe('getCurrent()', () => {
expect(current).toBe(70);
});
it('current time is the time to end + added time', () => {
it('current time is the time to end, ignoring added time', () => {
const state = {
eventNow: {
timeEnd: 100,
@@ -472,8 +497,9 @@ describe('getCurrent()', () => {
},
} as RuntimeState;
// counts to the fixed end; added time surfaces as offset, not extra countdown
const current = getCurrent(state);
expect(current).toBe(77);
expect(current).toBe(70);
});
it('handles events that finish the day after', () => {
@@ -482,6 +508,7 @@ describe('getCurrent()', () => {
timeStart: 79200000, // 22:00:00
timeEnd: 600000, // 00:10:00
countToEnd: true,
dayOffset: 0,
},
clock: 79500000, // 22:05:00
timer: {
@@ -492,6 +519,7 @@ describe('getCurrent()', () => {
rundown: {
actualStart: 79200000,
plannedEnd: 600000,
currentDay: 0,
},
_timer: {
pausedAt: null,
@@ -503,6 +531,35 @@ describe('getCurrent()', () => {
expect(current).toBe(dayInMs - 79500000 + 600000);
});
it('handles overnight count-to-end after midnight', () => {
const state = {
eventNow: {
timeStart: 23 * MILLIS_PER_HOUR, // 23:00:00
timeEnd: 1 * MILLIS_PER_HOUR, // 01:00:00
countToEnd: true,
dayOffset: 0,
},
clock: 30 * MILLIS_PER_MINUTE, // 00:30:00 on day 1
timer: {
addedTime: 0,
duration: Infinity,
startedAt: 23 * MILLIS_PER_HOUR,
},
rundown: {
actualStart: 23 * MILLIS_PER_HOUR,
plannedEnd: 1 * MILLIS_PER_HOUR,
currentDay: 1,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as RuntimeState;
const current = getCurrent(state);
expect(current).toBe(30 * MILLIS_PER_MINUTE);
});
it('handles events that were started late', () => {
const state = {
clock: 82000000, // 22:46:40 <--- starting 16 min after the scheduled end
@@ -1073,7 +1130,7 @@ describe('getRuntimeOffset()', () => {
expect(absolute).toBe(0);
});
it('with time-to-end, offset is the overtime', () => {
it('with time-to-end, offset combines overtime and added time', () => {
const state = {
clock: 82000000, // 22:46:40
eventNow: {
@@ -1126,7 +1183,45 @@ describe('getRuntimeOffset()', () => {
} as RuntimeState;
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(400000); // <--- offset is always the overtime
// overtime (400000) plus the operator's added time (-200000)
expect(absolute).toBe(200000);
});
it('with time-to-end, added time surfaces as offset', () => {
const state = {
clock: 80000000, // 22:13:20 - before the scheduled end, not in overtime
eventNow: {
id: 'd6a2ce',
timeStart: 77400000, // 21:30:00
timeEnd: 81000000, // 22:30:00
duration: 3600000, // 01:00:00
timeStrategy: TimeStrategy.LockEnd,
countToEnd: true,
dayOffset: 0,
delay: 0,
},
rundown: {
plannedStart: 77400000, // 21:30:00
plannedEnd: 81000000, // 22:30:00
actualStart: 78000000, // 21:40:00
currentDay: 0,
},
offset: {
absolute: 0,
},
timer: {
addedTime: 300000, // operator added 5 minutes
current: 1000000, // still counting down, no overtime
duration: 3600000,
startedAt: 78000000,
},
_startDayOffset: 0,
_timer: { pausedAt: null },
} as RuntimeState;
// the end is anchored, so the added 5 minutes shows up purely as offset
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(300000);
});
it('handles time-to-end started after the end time', () => {
@@ -0,0 +1,9 @@
/**
* Expose errors where we reach invalid states
* used mostly in shouldCrashDev patterns
*/
export class InvalidStateError extends Error {
constructor(message: string) {
super(message);
}
}
+25 -20
View File
@@ -2,6 +2,7 @@ import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types';
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
import type { RuntimeState } from '../stores/runtimeState.js';
import { InvalidStateError } from './generic.errors.js';
/**
* handle events that span over midnight
@@ -24,35 +25,35 @@ export function hasCrossedMidnight(previous: TimeOfDay, current: TimeOfDay): boo
* @returns {number | null} new current time or null if nothing is running
*/
export function getExpectedFinish(state: RuntimeState): MaybeNumber {
const { startedAt, duration, addedTime } = state.timer;
if (state.eventNow === null) {
// if there is a loaded event it must have started
// either way, we have no expected finish if nothing is playing
if (state.eventNow === null || state.timer.startedAt === null) {
return null;
}
const { countToEnd, timeEnd } = state.eventNow;
const { pausedAt } = state._timer;
const { clock } = state;
if (startedAt === null) {
return null;
if (state.eventNow.countToEnd) {
// count to end events are anchored to their fixed end: added time and pauses
// do not move the end, they surface as offset instead (see getRuntimeOffset)
return Math.max(state.eventNow.timeEnd, state.timer.startedAt);
}
const pausedTime = pausedAt != null ? clock - pausedAt : 0;
const pausedTime = state._timer.pausedAt != null ? state.clock - state._timer.pausedAt : 0;
if (countToEnd) {
return timeEnd + addedTime + pausedTime;
DEV: {
if (state.timer.duration === null) {
throw new InvalidStateError('a running timer cannot have null duration');
}
}
// handle events that finish the day after
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- duration exists if ther eis a timer
const expectedFinish = startedAt + duration! + addedTime + pausedTime;
const expectedFinish = state.timer.startedAt + state.timer.duration + state.timer.addedTime + pausedTime;
if (expectedFinish > dayInMs) {
return expectedFinish - dayInMs;
}
// an event cannot finish before it started (user added too much negative time)
return Math.max(expectedFinish, startedAt);
return Math.max(expectedFinish, state.timer.startedAt);
}
/**
@@ -74,9 +75,12 @@ export function getCurrent(state: RuntimeState): number {
const { clock } = state;
if (countToEnd) {
const isEventOverMidnight = timeStart > timeEnd;
const correctDay = isEventOverMidnight ? dayInMs : 0;
return correctDay - clock + timeEnd + addedTime;
// count to end runs to its fixed end, so added time does not stretch the countdown
const dayOffset = state.eventNow.dayOffset ?? 0;
const currentDay =
state.rundown.currentDay ?? (timeStart > timeEnd && clock <= timeEnd ? dayOffset + 1 : dayOffset);
const endDay = timeStart > timeEnd ? dayOffset + 1 : dayOffset;
return timeEnd + endDay * dayInMs - (clock + currentDay * dayInMs);
}
if (startedAt === null) {
@@ -180,13 +184,14 @@ export function getRuntimeOffset(state: RuntimeState): { absolute: number; relat
const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt;
// absolute offset is difference between schedule and playback time
const absolute = eventStartOffset + overtime + pausedTime + addedTime;
// count to end is anchored to its fixed end, so it absorbs the late start (eventStartOffset)
// and the pause (already reflected in overtime); added time surfaces here as offset
const absolute = countToEnd ? overtime + addedTime : eventStartOffset + overtime + pausedTime + addedTime;
// the relative offset is the same as the absolute but adjusted relative to the actual start time
const relative = absolute + plannedStart - actualStart - _startDayOffset * dayInMs;
// in case of count to end, the absolute offset is just the overtime
return countToEnd ? { absolute: overtime, relative } : { absolute, relative };
return { absolute, relative };
}
/**
@@ -56,18 +56,21 @@ const mockState = {
} as RuntimeState;
vi.mock('../../classes/data-provider/DataProvider.js', () => {
let automation = {
enabledAutomations: false,
enabledOscIn: false,
oscPortIn: 8888,
triggers: [],
automations: {},
};
return {
getDataProvider: vi.fn().mockImplementation(() => {
return {
getAutomation: vi.fn().mockImplementation(() => automation),
setCustomFields: vi.fn().mockImplementation((newData) => newData),
setRundown: vi.fn().mockImplementation((newData) => newData),
getAutomation: vi.fn().mockReturnValue({
enabledAutomations: false,
enabledOscIn: false,
oscPortIn: 0,
triggers: [],
automations: {},
}),
setAutomation: vi.fn().mockImplementation((newData) => (automation = newData)),
};
}),
};
@@ -325,6 +328,121 @@ describe('mutation on runtimeState', () => {
expect(newState.offset.expectedRundownEnd).toBeNull();
});
test('a countToEnd last event absorbs overtime into its fixed rundown end', async () => {
const entries = {
event1: {
...mockEvent,
id: 'event1',
timeStart: 10 * MILLIS_PER_HOUR,
timeEnd: 11 * MILLIS_PER_HOUR,
duration: MILLIS_PER_HOUR,
parent: null,
},
event2: {
...mockEvent,
id: 'event2',
timeStart: 11 * MILLIS_PER_HOUR,
timeEnd: 12 * MILLIS_PER_HOUR,
duration: MILLIS_PER_HOUR,
countToEnd: true,
linkStart: true,
parent: null,
},
};
const mockRundown = makeRundown({ entries, order: ['event1', 'event2'] });
await initRundown(mockRundown, {});
vi.runAllTimers();
const { metadata, rundown } = rundownCache.get();
// start event1 five minutes behind schedule
vi.setSystemTime('jan 1 10:05');
load(entries.event1, rundown, metadata);
start();
update();
const newState = getState();
expect(newState.offset.absolute).toBe(5 * MILLIS_PER_MINUTE);
// without countToEnd the rundown would end at 12h + 5min, but the countToEnd
// event absorbs the overtime so the rundown is still expected to end at 12h
expect(newState.offset.expectedRundownEnd).toBe(12 * MILLIS_PER_HOUR);
});
test('adding time to a running countToEnd event surfaces as offset and end remains fixed', async () => {
const entries = {
event1: {
...mockEvent,
id: 'event1',
timeStart: 10 * MILLIS_PER_HOUR,
timeEnd: 12 * MILLIS_PER_HOUR,
duration: 2 * MILLIS_PER_HOUR,
countToEnd: true,
parent: null,
},
};
const mockRundown = makeRundown({ entries, order: ['event1'] });
await initRundown(mockRundown, {});
vi.runAllTimers();
const { metadata, rundown } = rundownCache.get();
// start on time
vi.setSystemTime('jan 1 10:00');
load(entries.event1, rundown, metadata);
start();
update();
let state = getState();
expect(state.offset.absolute).toBe(0);
expect(state.timer.expectedFinish).toBe(12 * MILLIS_PER_HOUR);
expect(state.offset.expectedRundownEnd).toBe(12 * MILLIS_PER_HOUR);
// operator adds 5 minutes
addTime(5 * MILLIS_PER_MINUTE);
state = getState();
// the end cant move, so the added time is added to the offset
expect(state.offset.absolute).toBe(5 * MILLIS_PER_MINUTE);
expect(state.timer.expectedFinish).toBe(12 * MILLIS_PER_HOUR);
expect(state.offset.expectedRundownEnd).toBe(12 * MILLIS_PER_HOUR);
});
test('subtracting more than the remaining time does not finish a countToEnd event', async () => {
const entries = {
event1: {
...mockEvent,
id: 'event1',
timeStart: 10 * MILLIS_PER_HOUR,
timeEnd: 12 * MILLIS_PER_HOUR,
duration: 2 * MILLIS_PER_HOUR,
countToEnd: true,
parent: null,
},
};
const mockRundown = makeRundown({ entries, order: ['event1'] });
await initRundown(mockRundown, {});
vi.runAllTimers();
const { metadata, rundown } = rundownCache.get();
vi.setSystemTime('jan 1 10:00');
load(entries.event1, rundown, metadata);
start();
update();
// removing longer than the remaining time cannot finish the event since the end is fixed
addTime(-3 * MILLIS_PER_HOUR);
update();
const state = getState();
expect(state.timer.playback).toBe(Playback.Play);
expect(state.timer.expectedFinish).toBe(12 * MILLIS_PER_HOUR);
});
test('resume restores currentDay from restore point', async () => {
clearState();
const mockRundown = makeRundown({
@@ -1036,4 +1154,32 @@ describe('loadGroupFlagAndEnd()', () => {
eventNow: rundown.entries[0],
});
});
test('a countToEnd event breaks the link chain for the events that follow it', () => {
// chain: A (loaded) -> B (countToEnd, flagged) -> C (linked, last event)
// the chain stays intact up to and including B, but breaks for C since it follows a countToEnd event
const rundown = makeRundown({
entries: {
A: makeOntimeEvent({ id: 'A', parent: null, linkStart: false, countToEnd: false, gap: 0 }),
B: makeOntimeEvent({ id: 'B', parent: null, linkStart: true, countToEnd: true, gap: 0, flag: true }),
C: makeOntimeEvent({ id: 'C', parent: null, linkStart: true, countToEnd: false, gap: 0 }),
},
order: ['A', 'B', 'C'],
});
const state = {
groupNow: null,
eventNow: rundown.entries.A,
rundown: { actualGroupStart: null },
} as RuntimeState;
const metadata = { playableEventOrder: ['A', 'B', 'C'], flags: ['B'] } as RundownMetadata;
loadGroupFlagAndEnd(rundown, metadata, 0, state);
// the flag (B) is still part of the chain
expect(state._flag).toMatchObject({ event: rundown.entries.B, isLinkedToLoaded: true });
// the rundown end (C) follows the countToEnd event, so the chain is broken
expect(state._end).toMatchObject({ event: rundown.entries.C, isLinkedToLoaded: false });
});
});
+38 -23
View File
@@ -23,6 +23,7 @@ import {
calculateDuration,
checkIsNow,
dayInMs,
getExpectedEnd,
getExpectedStart,
getLastEventNormal,
isPlaybackActive,
@@ -43,6 +44,7 @@ import {
hasCrossedMidnight,
} from '../services/timerUtils.js';
import { timerConfig } from '../setup/config.js';
import { shouldCrashDev } from '../utils/development.js';
type ExpectedMetadata = {
event: OntimeEvent;
@@ -509,23 +511,31 @@ export function addTime(amount: number) {
}
}
// handle edge cases
// !!! we need to handle side effects before updating the state
const willGoNegative = amount < 0 && Math.abs(amount) > runtimeState.timer.current;
if (willGoNegative && !runtimeState._timer.hasFinished) {
// set finished time so side effects are triggered
runtimeState._timer.forceFinish = timeCore.timeOfDayNow();
if (runtimeState.eventNow?.countToEnd) {
// count to end is anchored to its fixed end: added time cannot move the end or finish
// the event early, it only surfaces as offset. `current` is derived from the wall clock
// by the update loop, so we must not bump it here.
runtimeState.timer.addedTime += amount;
} else {
const willGoPositive = runtimeState.timer.current < 0 && runtimeState.timer.current + amount > 0;
if (willGoPositive) {
runtimeState._timer.hasFinished = false;
// handle edge cases
// !!! we need to handle side effects before updating the state
const willGoNegative = amount < 0 && Math.abs(amount) > runtimeState.timer.current;
if (willGoNegative && !runtimeState._timer.hasFinished) {
// set finished time so side effects are triggered
runtimeState._timer.forceFinish = timeCore.timeOfDayNow();
} else {
const willGoPositive = runtimeState.timer.current < 0 && runtimeState.timer.current + amount > 0;
if (willGoPositive) {
runtimeState._timer.hasFinished = false;
}
}
// we can update the state after handling the side effects
runtimeState.timer.addedTime += amount;
runtimeState.timer.current += amount;
}
// we can update the state after handling the side effects
runtimeState.timer.addedTime += amount;
runtimeState.timer.current += amount;
runtimeState.timer.elapsed = getElapsed(runtimeState);
// update runtime delays: over - under
@@ -831,7 +841,6 @@ function getExpectedTimes(state = runtimeState) {
state.offset.expectedRundownEnd = null;
state.offset.expectedGroupEnd = null;
state.offset.expectedFlagStart = null;
state.offset.expectedRundownEnd = null;
const { offset } = state;
const { plannedStart, actualStart } = state.rundown;
@@ -841,9 +850,11 @@ function getExpectedTimes(state = runtimeState) {
if (state.groupNow) {
const { _group } = state;
if (_group !== null) {
const { event: lastEvent, accumulatedGap, isLinkedToLoaded } = _group;
const lastEventExpectedStart = getExpectedStart(lastEvent, {
DEV: shouldCrashDev(_group === null, 'groupNow is set but _group is null');
if (_group) {
const { event, accumulatedGap, isLinkedToLoaded } = _group;
state.offset.expectedGroupEnd = getExpectedEnd(event, {
currentDay: state.rundown.currentDay!,
totalGap: accumulatedGap,
isLinkedToLoaded,
@@ -852,15 +863,16 @@ function getExpectedTimes(state = runtimeState) {
plannedStart,
actualStart,
});
state.offset.expectedGroupEnd = lastEventExpectedStart + lastEvent.duration;
}
}
if (state.eventFlag) {
const { _flag } = state;
DEV: shouldCrashDev(_flag === null, 'eventFlag is set but _flag is null');
if (_flag) {
const { event, accumulatedGap, isLinkedToLoaded } = _flag;
const expectedStart = getExpectedStart(event, {
state.offset.expectedFlagStart = getExpectedStart(event, {
currentDay: state.rundown.currentDay!,
totalGap: accumulatedGap,
isLinkedToLoaded,
@@ -869,13 +881,13 @@ function getExpectedTimes(state = runtimeState) {
plannedStart,
actualStart,
});
state.offset.expectedFlagStart = expectedStart;
}
}
if (state._end) {
const { event, accumulatedGap, isLinkedToLoaded } = state._end;
const expectedStart = getExpectedStart(event, {
state.offset.expectedRundownEnd = getExpectedEnd(event, {
currentDay: state.rundown.currentDay!,
totalGap: accumulatedGap,
isLinkedToLoaded,
@@ -884,7 +896,6 @@ function getExpectedTimes(state = runtimeState) {
plannedStart,
actualStart,
});
state.offset.expectedRundownEnd = expectedStart + event.duration;
}
}
@@ -927,6 +938,8 @@ export function loadGroupFlagAndEnd(
let accumulatedGap = 0;
let isLinkedToLoaded = true;
// a countToEnd event absorbs overtime and breaks the chain
let previousWasCountToEnd = false;
for (let idx = currentIndex; idx < playableEventOrder.length; idx++) {
const entry = entries[playableEventOrder[idx]];
@@ -935,7 +948,7 @@ export function loadGroupFlagAndEnd(
if (idx !== currentIndex) {
// we only accumulate data after the loaded event
accumulatedGap += entry.gap;
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart;
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart && !previousWasCountToEnd;
// and the loaded event is not allowed to be the next flag
if (!foundFlag && metadata.flags.includes(entry.id)) {
@@ -949,6 +962,8 @@ export function loadGroupFlagAndEnd(
foundGroupEnd = true;
state._group = { event: lastEventInGroup, isLinkedToLoaded, accumulatedGap };
}
previousWasCountToEnd = entry.countToEnd;
}
}
+1 -1
View File
@@ -80,7 +80,7 @@ export { validateEndAction, validateTimerType } from './src/validate-events/vali
// feature business logic
export { getExpectedStart } from './src/date-utils/getExpectedStart.js';
export { getExpectedStart, getExpectedEnd } from './src/date-utils/getExpected.js';
// feature business logic - rundown
export { checkIsNow } from './src/date-utils/checkIsNow.js';
@@ -1,7 +1,7 @@
import { Day, OffsetMode } from 'ontime-types';
import { MILLIS_PER_HOUR, dayInMs } from './conversionUtils';
import { getExpectedStart } from './getExpectedStart';
import { getExpectedEnd, getExpectedStart } from './getExpected';
describe('getExpectedStart()', () => {
describe('Absolute offset mode', () => {
@@ -99,6 +99,36 @@ describe('getExpectedStart()', () => {
expect(getExpectedStart(testEvent, { ...testState, isLinkedToLoaded: false })).toBe(110); // <-- when gap is not enough to compensate for the running behind it absorbs at much as possible
// expect(getExpectedStart(testEvent, { ...testState, isLinkedToLoaded: true })).toBe(70); This should not be possible
});
test('negative delay cannot move a day 0 event before the rundown start', () => {
const testState = {
currentDay: 0,
totalGap: 0,
offset: 0,
mode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
isLinkedToLoaded: true,
};
expect(getExpectedStart({ timeStart: 0, delay: -5, dayOffset: 0 as Day }, testState)).toBe(0);
expect(getExpectedStart({ timeStart: 10, delay: -5, dayOffset: 0 as Day }, testState)).toBe(5);
});
test('negative delay can move a later-day event back to the previous day', () => {
const testState = {
currentDay: 0,
totalGap: 0,
offset: 0,
mode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
isLinkedToLoaded: true,
};
expect(getExpectedStart({ timeStart: 0, delay: -5, dayOffset: 1 as Day }, testState)).toBe(dayInMs - 5);
expect(getExpectedStart({ timeStart: 10, delay: -20, dayOffset: 1 as Day }, testState)).toBe(dayInMs - 10);
});
});
describe('Relative offset mode', () => {
@@ -315,3 +345,137 @@ describe('getExpectedStart()', () => {
expect(getExpectedStart(testEvent, { ...testState, currentDay: 0 })).toBe(23 * MILLIS_PER_HOUR + 5);
});
});
describe('getExpectedEnd()', () => {
const baseState = {
currentDay: 0,
totalGap: 0,
mode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
isLinkedToLoaded: true,
};
test('a regular event ends at its expected start plus duration', () => {
const testEvent = {
timeStart: 100,
duration: 50,
delay: 0,
dayOffset: 0 as Day,
countToEnd: false,
};
// on schedule
expect(getExpectedEnd(testEvent, { ...baseState, offset: 0 })).toBe(150);
// running 20 behind pushes the end out
expect(getExpectedEnd(testEvent, { ...baseState, offset: 20 })).toBe(170);
});
test('a countToEnd event pins to the planned end while in overtime', () => {
const testEvent = {
timeStart: 100,
duration: 50,
delay: 0,
dayOffset: 0 as Day,
countToEnd: true,
};
// overtime would otherwise push the end to 170, but countToEnd absorbs it and pins to 150
expect(getExpectedEnd(testEvent, { ...baseState, offset: 20 })).toBe(150);
});
test('an overnight countToEnd event returns a normalised end', () => {
// event starts at 23:00 and counts to 01:00 the next day -> duration spans midnight
const timeStart = 23 * MILLIS_PER_HOUR;
const duration = 2 * MILLIS_PER_HOUR;
const testEvent = {
timeStart,
duration,
delay: 0,
dayOffset: 0 as Day,
countToEnd: true,
};
expect(getExpectedEnd(testEvent, { ...baseState, offset: 0 })).toBe(timeStart + duration);
});
test('a countToEnd event ignores upstream delays and stays pinned to its fixed end', () => {
const testEvent = {
timeStart: 100,
duration: 50,
delay: 20,
dayOffset: 0 as Day,
};
// events shift their schedule based on delay...
expect(getExpectedEnd({ ...testEvent, countToEnd: false }, { ...baseState, offset: 0 })).toBe(170);
// ... but count to end events stay pinned to the scheduled end
expect(getExpectedEnd({ ...testEvent, countToEnd: true }, { ...baseState, offset: 0 })).toBe(150);
});
test('a countToEnd event drifts when a delay pushes its start past the fixed end', () => {
const testEvent = {
timeStart: 100,
duration: 50,
delay: 60,
dayOffset: 0 as Day,
countToEnd: true,
};
// the delayed start (160) is past the fixed end (150), so the event can no longer
// finish on time and the end follows the compromised start
expect(getExpectedEnd(testEvent, { ...baseState, offset: 0 })).toBe(160);
});
test('a countToEnd event on a later day keeps the day offset on the end', () => {
const testEvent = {
timeStart: 100,
duration: 50,
delay: 0,
dayOffset: 1 as Day,
countToEnd: true,
};
// the scheduled end must include the day offset (timeStart + dayInMs + duration),
// not collapse to the day-shifted start
expect(getExpectedEnd(testEvent, { ...baseState, currentDay: 0, offset: 0 })).toBe(150 + dayInMs);
// when the running event is already on the same day, no extra day is added
expect(getExpectedEnd({ ...testEvent, dayOffset: 0 as Day }, { ...baseState, currentDay: 0, offset: 0 })).toBe(150);
});
test('a countToEnd event with negative delay can start on the previous day but keeps its fixed end', () => {
const testEvent = {
timeStart: 0,
duration: 50,
delay: -10,
dayOffset: 1 as Day,
countToEnd: true,
};
expect(getExpectedStart(testEvent, { ...baseState, currentDay: 0, offset: 0 })).toBe(dayInMs - 10);
expect(getExpectedEnd(testEvent, { ...baseState, currentDay: 0, offset: 0 })).toBe(dayInMs + 50);
});
test('a countToEnd event is anchored to its wall-clock end in relative mode', () => {
const testEvent = {
timeStart: 100,
duration: 50,
delay: 0,
dayOffset: 0 as Day,
countToEnd: true,
};
const relativeState = {
...baseState,
mode: OffsetMode.Relative,
actualStart: 30,
plannedStart: 0,
offset: 0,
};
// a regular event in the same state is shifted by the relative-start offset to 180
expect(getExpectedEnd({ ...testEvent, countToEnd: false }, relativeState)).toBe(180);
// the countToEnd event stays pinned to its wall-clock end (150), not shifted
expect(getExpectedEnd(testEvent, relativeState)).toBe(150);
});
});
@@ -0,0 +1,75 @@
import type { MaybeNumber, OntimeEvent } from 'ontime-types';
import { OffsetMode } from 'ontime-types';
import { dayInMs } from './conversionUtils.js';
/**
* Runtime context shared by the expected start/end calculations
*/
type ExpectedTimesState = {
currentDay: number; // the current day from the rundown
totalGap: number; // accumulated gap from the current event
isLinkedToLoaded: boolean; // is this event part of a chain linking back to the loaded event
offset: number;
mode: OffsetMode;
actualStart: MaybeNumber;
plannedStart: MaybeNumber;
};
export function getExpectedStart(
event: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'>,
state: ExpectedTimesState,
): number {
const { timeStart, dayOffset, delay } = event;
const { currentDay, totalGap, isLinkedToLoaded, offset, mode, actualStart, plannedStart } = state;
const absoluteDelayedStart = Math.max(0, dayOffset * dayInMs + timeStart + delay);
const normalisedTimeStart = absoluteDelayedStart - currentDay * dayInMs;
let relativeStartOffset = 0;
if (mode === OffsetMode.Relative) {
relativeStartOffset = (actualStart ?? 0) + currentDay * dayInMs - (plannedStart ?? 0);
}
const scheduledStartTime = normalisedTimeStart + relativeStartOffset;
const offsetStartTime = scheduledStartTime + offset;
if (isLinkedToLoaded) {
//if we are directly linked back to the loaded event we just follow the offset
return offsetStartTime;
}
const gapsCanCompensateForOffset = totalGap > offset;
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 scheduledStartTime;
}
// otherwise consume as much of the offset as possible with the gap
const offsetStartTimeBufferedByGaps = offsetStartTime - totalGap;
return offsetStartTimeBufferedByGaps;
}
export function getExpectedEnd(
event: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay' | 'duration' | 'countToEnd'>,
state: ExpectedTimesState,
): number {
// expected start includes the delay and any offset compensation
const expectedStart = getExpectedStart(event, state);
/**
* Count to end events are a special case
* - the end time is always the wall clock
*/
if (event.countToEnd) {
const plannedEnd = event.dayOffset * dayInMs + event.timeStart + event.duration - state.currentDay * dayInMs;
// count to end should finish on the planned time or on start
return Math.max(expectedStart, plannedEnd);
}
// for normal events, the expected end is when we would start + its duration
return expectedStart + event.duration;
}
@@ -1,62 +0,0 @@
import type { MaybeNumber, OntimeEvent } from 'ontime-types';
import { OffsetMode } from 'ontime-types';
import { dayInMs } from './conversionUtils.js';
/**
* @param event the event that we are counting to
* @param currentDay the day offset of the currently running event
* @param totalGap accumulated gap from the current event
* @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event
* @param clock
* @param offset
* @returns
*/
export function getExpectedStart(
event: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'>,
state: {
currentDay: number; // the current day from the rundown
totalGap: number;
isLinkedToLoaded: boolean;
offset: number;
mode: OffsetMode;
actualStart: MaybeNumber;
plannedStart: MaybeNumber;
},
): number {
const { timeStart, dayOffset, delay } = event;
const { currentDay, totalGap, isLinkedToLoaded, offset, mode, actualStart, plannedStart } = state;
//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 (mode === OffsetMode.Relative) {
relativeStartOffset = (actualStart ?? 0) + currentDay * dayInMs - (plannedStart ?? 0);
}
const scheduledStartTime = normalisedTimeStart + relativeStartOffset;
const offsetStartTime = scheduledStartTime + offset;
if (isLinkedToLoaded) {
//if we are directly linked back to the loaded event we just follow the offset
return offsetStartTime;
}
const gapsCanCompensateForOffset = totalGap > offset;
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 scheduledStartTime;
}
// otherwise consume as much of the offset as possible with the gap
const offsetStartTimeBufferedByGaps = offsetStartTime - totalGap;
return offsetStartTimeBufferedByGaps;
}