mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-06 07:53:54 +00:00
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:
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user