diff --git a/.gitignore b/.gitignore
index e59ee987c..4e09da916 100644
--- a/.gitignore
+++ b/.gitignore
@@ -45,3 +45,6 @@ apps/server/src/preloaded-db/db.json
# versioning file
**/ONTIME_VERSION.js
+
+# temporary write files
+**.tmp
\ No newline at end of file
diff --git a/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.module.scss b/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.module.scss
index 0e64d02bb..a5a7b3987 100644
--- a/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.module.scss
+++ b/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.module.scss
@@ -86,16 +86,17 @@
grid-area: 2 / 2 / 2 / 4 ;
}
+.tag {
+ color: $label-gray;
+ font-size: calc(1rem - 2px);
+ margin-right: 0.25rem;
+}
+
.time {
color: $section-white;
font-size: $text-body-size;
}
-.tag {
- color: $label-gray;
- font-size: 13px;
-}
-
.rolltag {
color: $ontime-roll;
font-size: $text-body-size;
diff --git a/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.tsx b/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.tsx
index d8003c238..e3d77996f 100644
--- a/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.tsx
+++ b/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.tsx
@@ -1,6 +1,6 @@
import { Tooltip } from '@chakra-ui/react';
import { Playback } from 'ontime-types';
-import { millisToMinutes, millisToSeconds, millisToString } from 'ontime-utils';
+import { dayInMs, millisToMinutes, millisToSeconds, millisToString } from 'ontime-utils';
import { setPlayback, useTimer } from '../../../../common/hooks/useSocket';
import { tooltipDelayMid } from '../../../../ontimeConfig';
@@ -17,9 +17,10 @@ export default function PlaybackTimer(props: PlaybackTimerProps) {
const { playback } = props;
const timer = useTimer();
- // TODO: checkout typescript in utilities
const started = millisToString(timer.startedAt);
- const finish = millisToString(timer.expectedFinish);
+ const expectedFinish = timer.expectedFinish !== null ? timer.expectedFinish % dayInMs : null;
+ const finish = millisToString(expectedFinish);
+
const isRolling = playback === Playback.Roll;
const isStopped = playback === Playback.Stop;
const isWaiting = timer.secondaryTimer !== null && timer.secondaryTimer > 0 && timer.current === null;
@@ -72,11 +73,11 @@ export default function PlaybackTimer(props: PlaybackTimerProps) {
) : (
<>
- Started at
+ Started at
{started}
- Finish at
+ Expect end
{finish}
>
diff --git a/apps/client/src/features/overview/Overview.tsx b/apps/client/src/features/overview/Overview.tsx
index 16edfd8f3..9b5e6c377 100644
--- a/apps/client/src/features/overview/Overview.tsx
+++ b/apps/client/src/features/overview/Overview.tsx
@@ -1,5 +1,6 @@
+import { useMemo } from 'react';
import { MaybeNumber } from 'ontime-types';
-import { millisToString } from 'ontime-utils';
+import { dayInMs, millisToString } from 'ontime-utils';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import { useRuntimeOverview, useRuntimePlaybackOverview } from '../../common/hooks/useSocket';
@@ -19,9 +20,27 @@ function formatedTime(time: MaybeNumber) {
return millisToString(time, { fallback: timerPlaceholder });
}
+function calculateEndAndDaySpan(end: MaybeNumber): [MaybeNumber, number] {
+ let maybeEnd = end;
+ let maybeDaySpan = 0;
+ if (end !== null) {
+ if (end > dayInMs) {
+ maybeEnd = end % dayInMs;
+ maybeDaySpan = Math.floor(end / dayInMs);
+ }
+ }
+ return [maybeEnd, maybeDaySpan];
+}
+
export default function Overview() {
const { plannedEnd, plannedStart, actualStart, expectedEnd } = useRuntimeOverview();
+ const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
+ const plannedEndText = formatedTime(maybePlannedEnd);
+
+ const [maybeExpectedEnd, maybeExpectedDaySpan] = useMemo(() => calculateEndAndDaySpan(expectedEnd), [expectedEnd]);
+ const expectedEndText = formatedTime(maybeExpectedEnd);
+
return (
@@ -32,8 +51,8 @@ export default function Overview() {
-
-
+
+
diff --git a/apps/client/src/features/overview/composite/TimeLayout.module.scss b/apps/client/src/features/overview/composite/TimeLayout.module.scss
index 81c79d832..74be3a53b 100644
--- a/apps/client/src/features/overview/composite/TimeLayout.module.scss
+++ b/apps/client/src/features/overview/composite/TimeLayout.module.scss
@@ -20,8 +20,7 @@
flex-direction: column;
.label {
- line-height: 0.9em;
-
+ line-height: 0.9em;
}
}
@@ -29,6 +28,7 @@
display: flex;
align-items: center;
gap: 0.5rem;
+ height: 2.25em;
.label {
text-align: right;
@@ -38,3 +38,12 @@
font-size: 1.25rem;
}
}
+
+.daySpan {
+ &::after {
+ content: "*";
+ vertical-align: super;
+ font-size: 0.75em;
+ color: $blue-500;
+ }
+}
diff --git a/apps/client/src/features/overview/composite/TimeLayout.tsx b/apps/client/src/features/overview/composite/TimeLayout.tsx
index a0e0bfff1..aefc3cc5e 100644
--- a/apps/client/src/features/overview/composite/TimeLayout.tsx
+++ b/apps/client/src/features/overview/composite/TimeLayout.tsx
@@ -1,3 +1,5 @@
+import { Tooltip } from '@chakra-ui/react';
+
import { cx } from '../../../common/utils/styleUtils';
import style from './TimeLayout.module.scss';
@@ -5,6 +7,7 @@ import style from './TimeLayout.module.scss';
interface TimeLayoutProps {
label: string;
value: string;
+ daySpan?: number;
className?: string;
}
@@ -17,11 +20,17 @@ export function TimeColumn({ label, value, className }: TimeLayoutProps) {
);
}
-export function TimeRow({ label, value, className }: TimeLayoutProps) {
+export function TimeRow({ label, value, daySpan, className }: TimeLayoutProps) {
return (
{label}
- {value}
+ {daySpan ? (
+
+ {value}
+
+ ) : (
+ {value}
+ )}
);
}
diff --git a/apps/client/src/features/rundown/event-block/EventBlock.utils.ts b/apps/client/src/features/rundown/event-block/EventBlock.utils.ts
index bfb8a67ae..6213f2f87 100644
--- a/apps/client/src/features/rundown/event-block/EventBlock.utils.ts
+++ b/apps/client/src/features/rundown/event-block/EventBlock.utils.ts
@@ -1,5 +1,5 @@
import { MaybeNumber } from 'ontime-types';
-import { millisToString, removeLeadingZero, removeTrailingZero } from 'ontime-utils';
+import { dayInMs, millisToString, removeLeadingZero, removeTrailingZero } from 'ontime-utils';
export function formatDelay(timeStart: number, delay: number): string | undefined {
if (!delay) return;
@@ -23,10 +23,13 @@ export function formatOverlap(
if (previousStart && timeStart < previousEnd) {
const overlap = timeEnd - previousStart;
- if (overlap <= 0) return;
-
- const overlapString = removeLeadingZero(millisToString(Math.abs(overlap)));
- return `Overlap ${overlapString}`;
+ if (overlap > 0) {
+ const overlapString = removeLeadingZero(millisToString(Math.abs(overlap)));
+ return `Overlap ${overlapString}`;
+ }
+ const gap = timeStart + dayInMs - previousEnd;
+ const gapString = removeLeadingZero(millisToString(Math.abs(gap)));
+ return `Gap ${gapString} (next day)`;
}
const overlapString = removeLeadingZero(millisToString(Math.abs(overlap)));
diff --git a/apps/client/src/features/rundown/event-block/__tests__/EventBlock.utils.test.ts b/apps/client/src/features/rundown/event-block/__tests__/EventBlock.utils.test.ts
index 1b77e7512..ae3ff002e 100644
--- a/apps/client/src/features/rundown/event-block/__tests__/EventBlock.utils.test.ts
+++ b/apps/client/src/features/rundown/event-block/__tests__/EventBlock.utils.test.ts
@@ -20,20 +20,29 @@ describe('formatOverlap()', () => {
});
it('handles events the day after, without overlap', () => {
- const previousStart = new Date(0).setUTCHours(11).valueOf();
- const previousEnd = new Date(0).setUTCHours(12).valueOf();
- const timeStart = new Date(0).setUTCHours(6).valueOf();
- const timeEnd = new Date(0).setUTCHours(10).valueOf();
+ const previousStart = new Date(0).setUTCHours(11);
+ const previousEnd = new Date(0).setUTCHours(12);
+ const timeStart = new Date(0).setUTCHours(6);
+ const timeEnd = new Date(0).setUTCHours(10);
const result = formatOverlap(previousStart, previousEnd, timeStart, timeEnd);
- expect(result).toBeUndefined();
+ expect(result).toBe('Gap 18:00:00 (next day)');
});
it('handles events the day after, with overlap', () => {
- const previousStart = new Date(0).setUTCHours(9).valueOf();
- const previousEnd = new Date(0).setUTCHours(10).valueOf();
- const timeStart = new Date(0).setUTCHours(6).valueOf();
- const timeEnd = new Date(0).setUTCHours(11).valueOf();
+ const previousStart = new Date(0).setUTCHours(9);
+ const previousEnd = new Date(0).setUTCHours(10);
+ const timeStart = new Date(0).setUTCHours(6);
+ const timeEnd = new Date(0).setUTCHours(11);
const result = formatOverlap(previousStart, previousEnd, timeStart, timeEnd);
expect(result).toBe('Overlap 02:00:00');
});
+
+ it('handles events the day after, with gap', () => {
+ const previousStart = new Date(0).setUTCHours(17);
+ const previousEnd = new Date(0).setUTCHours(23);
+ const timeStart = new Date(0).setUTCHours(9);
+ const timeEnd = new Date(0).setUTCHours(11);
+ const result = formatOverlap(previousStart, previousEnd, timeStart, timeEnd);
+ expect(result).toBe('Gap 10:00:00 (next day)');
+ });
});
diff --git a/apps/client/src/features/rundown/time-input-flow/TimeInputFlow.tsx b/apps/client/src/features/rundown/time-input-flow/TimeInputFlow.tsx
index 472f4ff40..600cd1efb 100644
--- a/apps/client/src/features/rundown/time-input-flow/TimeInputFlow.tsx
+++ b/apps/client/src/features/rundown/time-input-flow/TimeInputFlow.tsx
@@ -109,12 +109,7 @@ const TimeInputFlow = (props: EventBlockTimerProps) => {
{overMidnight && (
-
+
diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts
index 213a5d5a4..afe3eb545 100644
--- a/apps/server/src/app.ts
+++ b/apps/server/src/app.ts
@@ -40,9 +40,8 @@ import { runtimeService } from './services/runtime-service/RuntimeService.js';
import { restoreService } from './services/RestoreService.js';
import { messageService } from './services/message-service/MessageService.js';
import { populateDemo } from './setup/loadDemo.js';
-import { getState, updateRundownData } from './stores/runtimeState.js';
+import { getState } from './stores/runtimeState.js';
import { initRundown } from './services/rundown-service/RundownService.js';
-import { getPlayableEvents } from './services/rundown-service/rundownUtils.js';
import { generateCrashReport } from './utils/generateCrashReport.js';
console.log(`Starting Ontime version ${ONTIME_VERSION}`);
@@ -183,9 +182,6 @@ export const startServer = async () => {
const persistedCustomFields = DataProvider.getCustomFields();
initRundown(persistedRundown, persistedCustomFields);
- // TODO: do this on the init of the runtime service
- updateRundownData(getPlayableEvents());
-
// load restore point if it exists
const maybeRestorePoint = await restoreService.load();
diff --git a/apps/server/src/services/TimerService.ts b/apps/server/src/services/TimerService.ts
index 993504bae..1197a9823 100644
--- a/apps/server/src/services/TimerService.ts
+++ b/apps/server/src/services/TimerService.ts
@@ -58,7 +58,7 @@ export class TimerService {
}
const state = runtimeState.getState();
- this.endCallback = setTimeout(this.update, state.timer.expectedFinish);
+ this.endCallback = setTimeout(() => this.update(), state.timer.expectedFinish);
return true;
}
@@ -97,7 +97,7 @@ export class TimerService {
// renew end callback
clearTimeout(this.endCallback);
const state = runtimeState.getState();
- this.endCallback = setTimeout(this.update, state.timer.expectedFinish);
+ this.endCallback = setTimeout(() => this.update(), state.timer.expectedFinish);
return true;
}
diff --git a/apps/server/src/services/__tests__/timerUtils.test.ts b/apps/server/src/services/__tests__/timerUtils.test.ts
index 089f8236a..69c9fb7f3 100644
--- a/apps/server/src/services/__tests__/timerUtils.test.ts
+++ b/apps/server/src/services/__tests__/timerUtils.test.ts
@@ -1,4 +1,4 @@
-import { dayInMs } from 'ontime-utils';
+import { MILLIS_PER_HOUR, dayInMs, millisToString } from 'ontime-utils';
import { EndAction, OntimeEvent, Playback, TimeStrategy, TimerType } from 'ontime-types';
import {
@@ -6,6 +6,7 @@ import {
getExpectedFinish,
getRollTimers,
getRuntimeOffset,
+ getTotalDuration,
normaliseEndTime,
skippedOutOfEvent,
updateRoll,
@@ -422,35 +423,6 @@ describe('getCurrent()', () => {
expect(current).toBe(77);
});
- it('handles events that start the day after', () => {
- const state = {
- eventNow: {
- timeStart: 60000, // 00:01:00
- timeEnd: 600000, // 00:10:00
- timerType: TimerType.TimeToEnd,
- },
- clock: 79500000, // 22:05:00
- timer: {
- addedTime: 0,
- duration: Infinity, // not relevant,
- startedAt: 79200000, // 22:00:00
- finishedAt: null,
- },
- runtime: {
- plannedStart: 60000, // 00:01:00
- plannedEnd: 79200000, // 22:00:00
- },
- _timer: {
- pausedAt: null,
- },
- } as RuntimeState;
-
- const current = getCurrent(state);
- // day - clock + start time
- const expectedCurrent = dayInMs - 79500000 + 60000;
- expect(current).toBe(expectedCurrent);
- });
-
it('handles events that finish the day after', () => {
const state = {
eventNow: {
@@ -477,6 +449,34 @@ describe('getCurrent()', () => {
const current = getCurrent(state);
expect(current).toBe(dayInMs - 79500000 + 600000);
});
+
+ it('handles events that were started late', () => {
+ const state = {
+ clock: 82000000, // 22:46:40 <--- starting 16 min after the scheduled end
+ eventNow: {
+ timeStart: 77400000, // 21:30:00
+ timeEnd: 81000000, // 22:30:00
+ duration: 3600000, // 01:00:00
+ timerType: TimerType.TimeToEnd,
+ },
+ timer: {
+ addedTime: 0,
+ duration: Infinity, // not relevant,
+ startedAt: 79200000, // 22:00:00
+ finishedAt: null,
+ },
+ runtime: {
+ actualStart: 82000000, // 22:46:40 <--- started now
+ plannedEnd: 81000000, // 22:30:00
+ },
+ _timer: {
+ pausedAt: null,
+ },
+ } as RuntimeState;
+
+ const current = getCurrent(state);
+ expect(current).toBe(81000000 - 82000000); // <-- planned end - now
+ });
});
});
@@ -1680,4 +1680,92 @@ describe('getRuntimeOffset()', () => {
const offset = getRuntimeOffset(state);
expect(offset).toBe(-400000);
});
+
+ it('handles time-to-end started after the end time', () => {
+ const state = {
+ clock: 82000000, // 22:46:40 <--- starting 16 min after the scheduled end
+ eventNow: {
+ id: 'd6a2ce',
+ type: 'event',
+ title: '',
+ timeStart: 77400000, // 21:30:00
+ timeEnd: 81000000, // 22:30:00
+ duration: 3600000, // 01:00:00
+ timeStrategy: TimeStrategy.LockEnd,
+ linkStart: null,
+ endAction: EndAction.None,
+ timerType: TimerType.TimeToEnd,
+ isPublic: true,
+ skip: false,
+ note: '',
+ colour: '',
+ cue: '1',
+ revision: 0,
+ timeWarning: 120000,
+ timeDanger: 60000,
+ custom: {},
+ delay: 0,
+ },
+ runtime: {
+ selectedEventIndex: 0,
+ numEvents: 1,
+ offset: null,
+ plannedStart: 77400000, // 21:30:00
+ plannedEnd: 81000000, // 22:30:00
+ actualStart: 82000000, // 22:46:40 <--- started now
+ expectedEnd: 82000000 + 3600000, // <--- now + duration
+ },
+ timer: {
+ addedTime: 0,
+ current: 0,
+ duration: 3600000,
+ elapsed: 0,
+ expectedFinish: 82000000 + 3600000, // <--- now + duration
+ finishedAt: null,
+ playback: Playback.Play,
+ secondaryTimer: null,
+ startedAt: 82000000, // <--- started now
+ },
+ _timer: { pausedAt: null, secondaryTarget: null },
+ } as RuntimeState;
+
+ const updateCurrent = getCurrent(state);
+ state.timer.current = updateCurrent;
+ const offset = getRuntimeOffset(state);
+ expect(offset).toBe(81000000 - 82000000); // <-- planned end - now
+ });
+});
+
+describe('getTotalDuration()', () => {
+ it('calculates the duration of events in a single day', () => {
+ const start = MILLIS_PER_HOUR * 9;
+ const end = MILLIS_PER_HOUR * 17;
+ const daySpan = 0;
+ const duration = getTotalDuration(start, end, daySpan);
+ expect(duration).toBe(MILLIS_PER_HOUR * (17 - 9));
+ });
+
+ it('calculates the duration of events across days', () => {
+ const start = MILLIS_PER_HOUR * 9;
+ const end = MILLIS_PER_HOUR * 17;
+ const daySpan = 1;
+ const duration = getTotalDuration(start, end, daySpan);
+ expect(duration).toBe(MILLIS_PER_HOUR * (17 - 9) + dayInMs);
+ });
+
+ it('calculates the duration of events across days (2)', () => {
+ const start = new Date(0).setHours(12);
+ const end = new Date(0).setHours(8);
+ const daySpan = 1;
+ const duration = getTotalDuration(start, end, daySpan);
+ expect(millisToString(duration)).toBe('20:00:00');
+ });
+
+ it('calculates the duration of events across days (3)', () => {
+ const start = new Date(0).setHours(9);
+ const end = new Date(0).setHours(23);
+ const daySpan = 2;
+ const duration = getTotalDuration(start, end, daySpan);
+ expect(millisToString(duration)).toBe('62:00:00');
+ });
});
diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts
index 2e3a44370..677619131 100644
--- a/apps/server/src/services/rundown-service/RundownService.ts
+++ b/apps/server/src/services/rundown-service/RundownService.ts
@@ -66,11 +66,12 @@ export async function addEvent(
const scopedMutation = cache.mutateCache(cache.add);
const { newEvent } = await scopedMutation({ atIndex, event: eventToAdd as OntimeRundownEntry });
- notifyChanges({ timer: [newEvent.id], external: true });
-
// notify runtime that rundown has changed
updateRuntimeOnChange();
+ // notify timer and external services of change
+ notifyChanges({ timer: [newEvent.id], external: true });
+
return newEvent;
}
@@ -82,10 +83,11 @@ export async function deleteEvent(eventId: string) {
const scopedMutation = cache.mutateCache(cache.remove);
await scopedMutation({ eventId });
- notifyChanges({ timer: [eventId], external: true });
-
- // notify event loader that rundown has changed
+ // notify runtime that rundown has changed
updateRuntimeOnChange();
+
+ // notify timer and external services of change
+ notifyChanges({ timer: [eventId], external: true });
}
/**
@@ -115,11 +117,12 @@ export async function editEvent(patch: Partial | Partial)
const scopedMutation = cache.mutateCache(cache.batchEdit);
await scopedMutation({ patch: data, eventIds: ids });
- notifyChanges({ timer: ids, external: true });
-
- // notify event loader that rundown has changed
+ // notify runtime that rundown has changed
updateRuntimeOnChange();
+
+ // notify timer and external services of change
+ notifyChanges({ timer: ids, external: true });
}
/**
@@ -148,11 +152,12 @@ export async function reorderEvent(eventId: string, from: number, to: number) {
const scopedMutation = cache.mutateCache(cache.reorder);
const reorderedItem = await scopedMutation({ eventId, from, to });
- notifyChanges({ timer: true, external: true });
-
- // notify event loader that rundown has changed
+ // notify runtime that rundown has changed
updateRuntimeOnChange();
+ // notify timer and external services of change
+ notifyChanges({ timer: true, external: true });
+
return reorderedItem;
}
@@ -160,6 +165,10 @@ export async function applyDelay(eventId: string) {
const scopedMutation = cache.mutateCache(cache.applyDelay);
await scopedMutation({ eventId });
+ // notify runtime that rundown has changed
+ updateRuntimeOnChange();
+
+ // notify timer and external services of change
notifyChanges({ timer: true, external: true });
}
@@ -173,10 +182,11 @@ export async function swapEvents(from: string, to: string) {
const scopedMutation = cache.mutateCache(cache.swap);
await scopedMutation({ fromId: from, toId: to });
- notifyChanges({ timer: true, external: true });
-
- // notify event loader that rundown has changed
+ // notify runtime that rundown has changed
updateRuntimeOnChange();
+
+ // notify timer and external services of change
+ notifyChanges({ timer: true, external: true });
}
/**
@@ -184,8 +194,17 @@ export async function swapEvents(from: string, to: string) {
* Called when we make changes to the rundown object
*/
function updateRuntimeOnChange() {
+ const playableEvents = getPlayableEvents();
+ const numEvents = playableEvents.length;
+ const metadata = cache.getMetadata();
+
// schedule an update for the end of the event loop
- setImmediate(() => updateRundownData(getPlayableEvents()));
+ setImmediate(() =>
+ updateRundownData({
+ numEvents,
+ ...metadata,
+ }),
+ );
}
/**
@@ -212,6 +231,11 @@ export function notifyChanges(options: { timer?: boolean | string[]; external?:
*/
export async function initRundown(rundown: OntimeRundown, customFields: CustomFields) {
await cache.init(rundown, customFields);
+
+ // notify runtime that rundown has changed
+ updateRuntimeOnChange();
+
+ // notify timer of change
notifyChanges({ timer: true });
}
diff --git a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts
index 04888ec37..000bbe597 100644
--- a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts
+++ b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts
@@ -10,6 +10,7 @@ import {
TimeStrategy,
TimerType,
} from 'ontime-types';
+import { MILLIS_PER_HOUR, dayInMs, millisToString } from 'ontime-utils';
import { calculateRuntimeDelays, getDelayAt, calculateRuntimeDelaysFrom } from '../delayUtils.js';
import {
@@ -25,7 +26,7 @@ import {
removeCustomField,
} from '../rundownCache.js';
-describe('init() function', () => {
+describe('generate()', () => {
it('creates normalised versions of a given rundown', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Event, id: '1' } as OntimeEvent,
@@ -71,6 +72,7 @@ describe('init() function', () => {
expect((initResult.rundown['3'] as OntimeEvent).delay).toBe(100);
expect((initResult.rundown['4'] as OntimeEvent).delay).toBe(0);
expect(initResult.totalDelay).toBe(0);
+ expect(initResult.totalDuration).toBe(700 - 100);
});
it('handles negative delays', () => {
@@ -91,6 +93,7 @@ describe('init() function', () => {
expect((initResult.rundown['3'] as OntimeEvent).delay).toBe(-200);
expect((initResult.rundown['4'] as OntimeEvent).delay).toBe(-200);
expect(initResult.totalDelay).toBe(-200);
+ expect(initResult.totalDuration).toBe(700 - 100);
});
it('links times across events', () => {
@@ -153,6 +156,68 @@ describe('init() function', () => {
expect(initResult.links['3']).toBe('2');
});
+ it('calculates total duration', () => {
+ const testRundown: OntimeRundown = [
+ { type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 200 } as OntimeEvent,
+ { type: SupportedEvent.Event, id: '2', timeStart: 200, timeEnd: 300 } as OntimeEvent,
+ { type: SupportedEvent.Event, id: '3', timeStart: 300, timeEnd: 400 } as OntimeEvent,
+ ];
+
+ const initResult = generate(testRundown);
+ expect(initResult.order.length).toBe(3);
+ expect(initResult.totalDuration).toBe(400 - 100);
+ });
+
+ it('calculates total duration across days with gap', () => {
+ const testRundown: OntimeRundown = [
+ {
+ type: SupportedEvent.Event,
+ id: '1',
+ timeStart: new Date(0).setHours(9),
+ timeEnd: new Date(0).setHours(23),
+ } as OntimeEvent,
+ {
+ type: SupportedEvent.Event,
+ id: '2',
+ timeStart: new Date(0).setHours(9),
+ timeEnd: new Date(0).setHours(23),
+ } as OntimeEvent,
+ {
+ type: SupportedEvent.Event,
+ id: '3',
+ timeStart: new Date(0).setHours(9),
+ timeEnd: new Date(0).setHours(23),
+ } as OntimeEvent,
+ ];
+
+ const initResult = generate(testRundown);
+ const expectedDuration = (23 - 9 + 48) * MILLIS_PER_HOUR;
+ expect(millisToString(initResult.totalDuration)).toBe('62:00:00');
+ expect(initResult.totalDuration).toBe(expectedDuration);
+ });
+
+ it('calculates total duration across days', () => {
+ const testRundown: OntimeRundown = [
+ {
+ type: SupportedEvent.Event,
+ id: '1',
+ timeStart: new Date(0).setHours(12),
+ timeEnd: new Date(0).setHours(22),
+ } as OntimeEvent,
+ {
+ type: SupportedEvent.Event,
+ id: '2',
+ timeStart: new Date(0).setHours(22),
+ timeEnd: new Date(0).setHours(8),
+ } as OntimeEvent,
+ ];
+
+ const initResult = generate(testRundown);
+ const expectedDuration = 8 * MILLIS_PER_HOUR + (dayInMs - 12 * MILLIS_PER_HOUR);
+ expect(millisToString(initResult.totalDuration)).toBe('20:00:00');
+ expect(initResult.totalDuration).toBe(expectedDuration);
+ });
+
it('handles updating event sequence', () => {
const testRundown: OntimeRundown = [
{
diff --git a/apps/server/src/services/rundown-service/rundownCache.ts b/apps/server/src/services/rundown-service/rundownCache.ts
index 6d42244fc..79bba59b6 100644
--- a/apps/server/src/services/rundown-service/rundownCache.ts
+++ b/apps/server/src/services/rundown-service/rundownCache.ts
@@ -4,6 +4,7 @@ import {
CustomFields,
isOntimeDelay,
isOntimeEvent,
+ MaybeNumber,
OntimeEvent,
OntimeRundown,
OntimeRundownEntry,
@@ -12,6 +13,7 @@ import { generateId, deleteAtIndex, insertAtIndex, reorderArray, swapEventData }
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { createPatch } from '../../utils/parser.js';
+import { getTotalDuration } from '../timerUtils.js';
import { apply } from './delayUtils.js';
import { handleCustomField, handleLink } from './rundownCacheUtils.js';
@@ -30,6 +32,9 @@ let order: EventID[] = [];
let revision = 0;
let isStale = true;
let totalDelay = 0;
+let totalDuration = 0;
+let firstStart: MaybeNumber = null;
+let lastEnd: MaybeNumber = null;
let links: Record = {};
@@ -78,8 +83,11 @@ export function generate(
rundown = {};
order = [];
links = {};
+ firstStart = null;
+ lastEnd = null;
let accumulatedDelay = 0;
+ let daySpan = 0;
let previousEnd: number;
for (let i = 0; i < initialRundown.length; i++) {
@@ -95,6 +103,19 @@ export function generate(
// update the persisted event
initialRundown[i] = updatedEvent;
+
+ // update rundown duration
+ if (firstStart === null) {
+ firstStart = updatedEvent.timeStart;
+ }
+ lastEnd = updatedEvent.timeEnd;
+
+ // check if we go over midnight, account for eventual gaps
+ const gapOverMidnight = previousEnd > updatedEvent.timeStart;
+ const durationOverMidnight = updatedEvent.timeStart > updatedEvent.timeEnd;
+ if (gapOverMidnight || durationOverMidnight) {
+ daySpan++;
+ }
}
// calculate delays
@@ -119,7 +140,9 @@ export function generate(
isStale = false;
totalDelay = accumulatedDelay;
- return { rundown, order, links, totalDelay, assignedCustomProperties: assignedCustomFields };
+ totalDuration = getTotalDuration(firstStart, lastEnd, daySpan);
+
+ return { rundown, order, links, totalDelay, totalDuration, assignedCustomProperties: assignedCustomFields };
}
/** Returns an ID guaranteed to be unique */
@@ -146,6 +169,8 @@ type RundownCache = {
rundown: NormalisedRundown;
order: string[];
revision: number;
+ totalDelay: number;
+ totalDuration: number;
};
/**
@@ -162,6 +187,26 @@ export function get(): Readonly {
rundown,
order,
revision,
+ totalDelay,
+ totalDuration,
+ };
+}
+
+/**
+ * Returns calculated metadata from rundown
+ */
+export function getMetadata() {
+ if (isStale) {
+ console.time('rundownCache__init');
+ generate();
+ console.timeEnd('rundownCache__init');
+ }
+
+ return {
+ firstStart,
+ lastEnd,
+ totalDelay,
+ totalDuration,
};
}
diff --git a/apps/server/src/services/runtime-service/RuntimeService.ts b/apps/server/src/services/runtime-service/RuntimeService.ts
index a8ee8f97e..0e0a41f00 100644
--- a/apps/server/src/services/runtime-service/RuntimeService.ts
+++ b/apps/server/src/services/runtime-service/RuntimeService.ts
@@ -70,7 +70,7 @@ class RuntimeService {
this.eventTimer = new TimerService({
refresh: timerConfig.updateRate,
updateInterval: timerConfig.notificationRate,
- onUpdateCallback: this.checkTimerUpdate.bind(this),
+ onUpdateCallback: () => this.checkTimerUpdate,
});
if (resumable) {
diff --git a/apps/server/src/services/timerUtils.ts b/apps/server/src/services/timerUtils.ts
index 5a57a160c..177000e32 100644
--- a/apps/server/src/services/timerUtils.ts
+++ b/apps/server/src/services/timerUtils.ts
@@ -62,12 +62,6 @@ export function getCurrent(state: RuntimeState): number {
if (timerType === TimerType.TimeToEnd) {
const isEventOverMidnight = timeStart > timeEnd;
- const hasFinishedRundownForToday = state.runtime.plannedEnd && clock > state.runtime.plannedEnd;
-
- if (hasFinishedRundownForToday && !isEventOverMidnight) {
- return dayInMs - clock + state.eventNow.timeStart + addedTime;
- }
-
const correctDay = isEventOverMidnight ? dayInMs : 0;
return correctDay - clock + timeEnd + addedTime;
}
@@ -76,12 +70,12 @@ export function getCurrent(state: RuntimeState): number {
return duration;
}
- const hasPassedMidnight = startedAt > clock;
- const correctDay = hasPassedMidnight ? dayInMs : 0;
if (pausedAt != null) {
return startedAt + duration + addedTime - pausedAt;
}
+ const hasPassedMidnight = startedAt > clock;
+ const correctDay = hasPassedMidnight ? dayInMs : 0;
return startedAt + duration + addedTime - clock - correctDay;
}
@@ -329,3 +323,34 @@ export function getRuntimeOffset(state: RuntimeState): MaybeNumber {
return startOffset + addedTime + pausedTime + Math.abs(overtime);
}
+
+/**
+ * Calculates total duration of a time span
+ * @param firstStart
+ * @param lastEnd
+ * @param daySpan
+ * @returns
+ */
+export function getTotalDuration(firstStart: number, lastEnd: number, daySpan: number): number {
+ if (!lastEnd) {
+ return 0;
+ }
+ let correctDay = 0;
+ if (lastEnd < firstStart) {
+ correctDay = dayInMs;
+ daySpan -= 1;
+ }
+ // eslint-disable-next-line prettier/prettier -- we like the clarity
+ return lastEnd + correctDay + daySpan * dayInMs - firstStart;
+}
+
+/**
+ * Calculates the expected end of the rundown
+ */
+export function getExpectedEnd(state: RuntimeState): MaybeNumber {
+ // there is no expected end if we havent started
+ if (state.runtime.actualStart === null) {
+ return null;
+ }
+ return state.runtime.plannedEnd + state.runtime.offset + state._timer.totalDelay;
+}
diff --git a/apps/server/src/stores/__tests__/runtimeState.test.ts b/apps/server/src/stores/__tests__/runtimeState.test.ts
index f9ae0c20b..4357c8a03 100644
--- a/apps/server/src/stores/__tests__/runtimeState.test.ts
+++ b/apps/server/src/stores/__tests__/runtimeState.test.ts
@@ -2,6 +2,7 @@ import { OntimeEvent, Playback } from 'ontime-types';
import { deepmerge } from 'ontime-utils';
import { RuntimeState, addTime, clear, getState, load, pause, start, stop } from '../runtimeState.js';
+import { initRundown } from '../../services/rundown-service/RundownService.js';
const mockEvent = {
type: 'event',
@@ -48,17 +49,22 @@ describe('mutation on runtimeState', () => {
beforeEach(() => {
clear();
- vi.mock('../../services/rundown-service/RundownService.js', () => ({
- getPlayableEvents: vi.fn().mockReturnValue([
- {
- id: 'mock',
- cue: 'mock',
- timeStart: 0,
- timeEnd: 1000,
- duration: 1000,
- },
- ]),
- }));
+ vi.mock('../../services/rundown-service/RundownService.js', async (importOriginal) => {
+ const actual = (await importOriginal()) as object;
+
+ return {
+ ...actual,
+ getPlayableEvents: vi.fn().mockReturnValue([
+ {
+ id: 'mock',
+ cue: 'mock',
+ timeStart: 0,
+ timeEnd: 1000,
+ duration: 1000,
+ },
+ ]),
+ };
+ });
});
afterEach(() => {
@@ -137,10 +143,12 @@ describe('mutation on runtimeState', () => {
expect(newState.runtime.actualStart).toBeNull();
});
+ // do this before the test so that it is applied
+ const event1 = { ...mockEvent, id: 'event1', timeStart: 0, timeEnd: 1000, duration: 1000 };
+ const event2 = { ...mockEvent, id: 'event2', timeStart: 1000, timeEnd: 1500, duration: 500 };
+ // force update
+ initRundown([event1, event2], {});
test('runtime offset', () => {
- const event1 = { ...mockEvent, id: 'event1', timeStart: 0, timeEnd: 1000, duration: 1000 };
- const event2 = { ...mockEvent, id: 'event2', timeStart: 1000, timeEnd: 1500, duration: 500 };
-
// 1. Load event
load(event1, [event1, event2]);
let newState = getState();
diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts
index cd4234f52..8a050227a 100644
--- a/apps/server/src/stores/runtimeState.ts
+++ b/apps/server/src/stores/runtimeState.ts
@@ -1,11 +1,12 @@
import { MaybeNumber, OntimeEvent, Playback, Runtime, TimerState, TimerType } from 'ontime-types';
-import { calculateDuration, dayInMs, getFirstEvent, getLastEvent } from 'ontime-utils';
+import { calculateDuration, dayInMs } from 'ontime-utils';
import { clock } from '../services/Clock.js';
import { RestorePoint } from '../services/RestoreService.js';
import {
getCurrent,
+ getExpectedEnd,
getExpectedFinish,
getRollTimers,
getRuntimeOffset,
@@ -46,6 +47,7 @@ export type RuntimeState = {
timer: TimerState;
// private properties of the timer calculations
_timer: {
+ totalDelay: number; // this value comes from rundown service
pausedAt: MaybeNumber;
secondaryTarget: MaybeNumber;
};
@@ -60,6 +62,7 @@ const runtimeState: RuntimeState = {
runtime: initialRuntime,
timer: { ...initialTimer },
_timer: {
+ totalDelay: 0,
pausedAt: null,
secondaryTarget: null,
},
@@ -85,10 +88,10 @@ export function clear() {
runtimeState.timer.playback = Playback.Stop;
runtimeState.clock = clock.timeNow();
runtimeState.timer = { ...initialTimer };
- runtimeState._timer = {
- pausedAt: null,
- secondaryTarget: null,
- };
+
+ // we maintain the total delay
+ runtimeState._timer.pausedAt = null;
+ runtimeState._timer.secondaryTarget = null;
}
/**
@@ -103,23 +106,25 @@ function patchTimer(newState: Partial) {
}
}
+type RundownData = {
+ numEvents: number;
+ firstStart: MaybeNumber;
+ lastEnd: MaybeNumber;
+ totalDelay: number;
+ totalDuration: number;
+};
+
/**
* Utility, allows updating data derived from the rundown
* @param playableRundown
*/
-export function updateRundownData(playableRundown: OntimeEvent[]) {
- runtimeState.runtime.numEvents = playableRundown.length;
+export function updateRundownData(rundownData: RundownData) {
+ runtimeState._timer.totalDelay = rundownData.totalDelay;
- const { firstEvent } = getFirstEvent(playableRundown);
- const { lastEvent } = getLastEvent(playableRundown);
-
- runtimeState.runtime.plannedStart = firstEvent?.timeStart ?? null;
- runtimeState.runtime.plannedEnd = lastEvent?.timeEnd ?? null;
- if (runtimeState.runtime.plannedEnd === null || !runtimeState.runtime.actualStart) {
- runtimeState.runtime.expectedEnd = null;
- } else {
- runtimeState.runtime.expectedEnd = (runtimeState.runtime.plannedEnd + runtimeState.runtime.offset) % dayInMs;
- }
+ runtimeState.runtime.numEvents = rundownData.numEvents;
+ runtimeState.runtime.plannedStart = rundownData.firstStart;
+ runtimeState.runtime.plannedEnd = rundownData.firstStart + rundownData.totalDuration;
+ runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
}
/**
@@ -135,12 +140,9 @@ export function load(
): boolean {
clear();
- updateRundownData(rundown);
-
const eventIndex = rundown.findIndex((eventInMemory) => eventInMemory.id === event.id);
runtimeState.runtime.selectedEventIndex = eventIndex;
- runtimeState.runtime.numEvents = rundown.length;
loadNow(event, rundown);
loadNext(rundown);
@@ -157,7 +159,7 @@ export function load(
if (firstStart === null || typeof firstStart === 'number') {
runtimeState.runtime.actualStart = firstStart;
runtimeState.runtime.offset = getRuntimeOffset(runtimeState);
- runtimeState.runtime.expectedEnd = (runtimeState.runtime.plannedEnd + runtimeState.runtime.offset) % dayInMs;
+ runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
}
}
@@ -349,9 +351,8 @@ export function addTime(amount: number) {
// update runtime delays: over - under
runtimeState.runtime.offset = getRuntimeOffset(runtimeState);
- if (runtimeState.runtime.offset !== null) {
- runtimeState.runtime.expectedEnd = (runtimeState.runtime.plannedEnd + runtimeState.runtime.offset) % dayInMs;
- }
+ runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
+
return true;
}
@@ -367,9 +368,6 @@ export function update(): UpdateResult {
const previousTime = runtimeState.clock;
runtimeState.clock = clock.timeNow();
- // update offset
- runtimeState.runtime.offset = getRuntimeOffset(runtimeState);
-
// we call integrations if we update timers
if (runtimeState.timer.playback === Playback.Roll) {
const result = onRollUpdate();
@@ -385,6 +383,9 @@ export function update(): UpdateResult {
runtimeState.timer.duration = runtimeState.timer.current;
}
+ // update offset
+ runtimeState.runtime.offset = getRuntimeOffset(runtimeState);
+
return {
hasTimerFinished,
shouldCallRoll,