From a91a8a6358a8b762ea058aae1ec66c0cbe82ca66 Mon Sep 17 00:00:00 2001 From: Carlos Valente <34649812+cpvalente@users.noreply.github.com> Date: Mon, 18 Mar 2024 17:24:11 +0100 Subject: [PATCH] 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 --- .gitignore | 3 + .../playback-timer/PlaybackTimer.module.scss | 11 +- .../playback/playback-timer/PlaybackTimer.tsx | 11 +- .../client/src/features/overview/Overview.tsx | 25 ++- .../overview/composite/TimeLayout.module.scss | 13 +- .../overview/composite/TimeLayout.tsx | 13 +- .../rundown/event-block/EventBlock.utils.ts | 13 +- .../__tests__/EventBlock.utils.test.ts | 27 ++-- .../rundown/time-input-flow/TimeInputFlow.tsx | 7 +- apps/server/src/app.ts | 6 +- apps/server/src/services/TimerService.ts | 4 +- .../src/services/__tests__/timerUtils.test.ts | 148 ++++++++++++++---- .../rundown-service/RundownService.ts | 60 ++++--- .../__tests__/rundownCache.test.ts | 67 +++++++- .../services/rundown-service/rundownCache.ts | 47 +++++- .../runtime-service/RuntimeService.ts | 2 +- apps/server/src/services/timerUtils.ts | 41 ++++- .../src/stores/__tests__/runtimeState.test.ts | 36 +++-- apps/server/src/stores/runtimeState.ts | 55 +++---- 19 files changed, 445 insertions(+), 144 deletions(-) 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,