refactor: simplify time-to-end (#828)

* refactor: simplify time-to-end

* refactor: rundown metadata

* refactor: handle overflow in UI

* refactor: show gaps between days

---------

Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>
This commit is contained in:
Carlos Valente
2024-03-18 17:24:11 +01:00
committed by GitHub
parent 4e6b833e10
commit a91a8a6358
19 changed files with 445 additions and 144 deletions
+3
View File
@@ -45,3 +45,6 @@ apps/server/src/preloaded-db/db.json
# versioning file
**/ONTIME_VERSION.js
# temporary write files
**.tmp
@@ -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;
@@ -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) {
) : (
<>
<div className={style.start}>
<span className={style.tag}>Started at </span>
<span className={style.tag}>Started at</span>
<span className={style.time}>{started}</span>
</div>
<div className={style.finish}>
<span className={style.tag}>Finish at </span>
<span className={style.tag}>Expect end</span>
<span className={style.time}>{finish}</span>
</div>
</>
+22 -3
View File
@@ -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 (
<div className={style.overview}>
<ErrorBoundary>
@@ -32,8 +51,8 @@ export default function Overview() {
</div>
<RuntimeOverview />
<div className={style.column}>
<TimeRow label='Planned end' value={formatedTime(plannedEnd)} className={style.end} />
<TimeRow label='Expected end' value={formatedTime(expectedEnd)} className={style.end} />
<TimeRow label='Planned end' value={plannedEndText} className={style.end} daySpan={maybePlannedDaySpan} />
<TimeRow label='Expected end' value={expectedEndText} className={style.end} daySpan={maybeExpectedDaySpan} />
</div>
</ErrorBoundary>
</div>
@@ -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;
}
}
@@ -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 (
<div className={style.row}>
<span className={style.label}>{label}</span>
<span className={cx([style.clock, className])}>{value}</span>
{daySpan ? (
<Tooltip label={`Event spans over ${daySpan + 1} days`}>
<span className={cx([style.clock, style.daySpan, className])}>{value}</span>
</Tooltip>
) : (
<span className={cx([style.clock, className])}>{value}</span>
)}
</div>
);
}
@@ -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)));
@@ -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)');
});
});
@@ -109,12 +109,7 @@ const TimeInputFlow = (props: EventBlockTimerProps) => {
{overMidnight && (
<div className={style.timerNote}>
<Tooltip
label='Over midnight: end time is before start'
openDelay={tooltipDelayFast}
variant='ontime-ondark'
shouldWrapChildren
>
<Tooltip label='Over midnight' openDelay={tooltipDelayFast} variant='ontime-ondark' shouldWrapChildren>
<IoAlertCircleOutline />
</Tooltip>
</div>
+1 -5
View File
@@ -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();
+2 -2
View File
@@ -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;
}
@@ -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');
});
});
@@ -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<OntimeEvent> | Partial<OntimeBloc
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know patch has an id
const { newEvent } = await scopedMutation({ patch, eventId: patch.id! });
notifyChanges({ timer: [patch.id], 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: [patch.id], external: true });
return newEvent;
}
@@ -132,10 +135,11 @@ export async function batchEditEvents(ids: string[], data: Partial<OntimeEvent>)
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 });
}
@@ -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 = [
{
@@ -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<EventID, EventID> = {};
@@ -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<RundownCache> {
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,
};
}
@@ -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) {
+33 -8
View File
@@ -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;
}
@@ -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();
+28 -27
View File
@@ -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<TimerState>) {
}
}
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,