From 1fad99f505fb2c3f5fe1ec0d7a11173f24ac7177 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 15:46:58 +0000 Subject: [PATCH] refactor(runtime): derive the group timer on the server The group timer was derived in the client, which meant every view that wanted it had to depend on the rundown query. That coupling was already awkward for the PiP timer, whose separate react root needed its own query client, and it would have spread to every remaining view. Moving the derivation to the server removes the coupling. The value is still the running event timer offset by the content scheduled around it, so the group keeps behaving as if it were a single event containing all its children. Because the group timer is the event timer offset by a constant, it changes exactly when the event timer does and can share its broadcast throttling. - add RuntimeStore.groupTimer, null unless the running group opted in - split the group around the loaded event when the group is loaded, so the per tick cost is an addition rather than a walk of the rundown - derive on the getState() projection so it cannot drift from the timer it is built on - reduce the client to a plain selector, dropping the PiP query client workaround Also fixes elapsed time, which was calculated from the group duration and so clamped to zero for as long as time added to an event kept the group in credit. It is now derived symmetrically with the remaining time, and the two always add up to the total. Timer and PiP shared eight identical branches for choosing between the two timers. These now go through a single resolver. A group has no warning or danger thresholds, so it only reports as running or overtime, and feeding that phase through the existing modifiers makes the suppression of warning and danger a consequence of what a group is rather than something each view has to remember. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Kniqs443KUNCRABwVJwT7K --- apps/client/src/common/hooks/useGroupTimer.ts | 52 ------ apps/client/src/common/hooks/useSocket.ts | 8 +- .../common/utils/__tests__/groupTimer.test.ts | 93 ----------- apps/client/src/common/utils/groupTimer.ts | 48 ------ .../features/control/message/TimerPreview.tsx | 5 +- .../src/views/editor/pip-timer/PipHost.tsx | 7 +- .../src/views/editor/pip-timer/PipTimer.tsx | 47 +++--- apps/client/src/views/timer/Timer.tsx | 49 +++--- apps/client/src/views/timer/timer.utils.ts | 53 ++++++ apps/server/src/app.ts | 1 + .../src/services/__tests__/timerUtils.test.ts | 151 +++++++++++++++++- .../runtime-service/runtime.service.ts | 12 ++ apps/server/src/services/timerUtils.ts | 88 +++++++++- .../stores/__mocks__/runtimeState.mocks.ts | 2 + apps/server/src/stores/runtimeState.ts | 23 +++ .../src/definitions/runtime/RuntimeStore.ts | 1 + .../definitions/runtime/RuntimeStore.type.ts | 4 +- .../definitions/runtime/TimerState.type.ts | 16 ++ packages/types/src/index.ts | 2 +- 19 files changed, 395 insertions(+), 267 deletions(-) delete mode 100644 apps/client/src/common/hooks/useGroupTimer.ts delete mode 100644 apps/client/src/common/utils/__tests__/groupTimer.test.ts delete mode 100644 apps/client/src/common/utils/groupTimer.ts diff --git a/apps/client/src/common/hooks/useGroupTimer.ts b/apps/client/src/common/hooks/useGroupTimer.ts deleted file mode 100644 index c7789e896..000000000 --- a/apps/client/src/common/hooks/useGroupTimer.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { MaybeNumber } from 'ontime-types'; -import { useMemo } from 'react'; - -import useRundown from '../hooks-query/useRundown'; -import { getRemainingGroupTime } from '../utils/groupTimer'; -import { useGroupTimerData } from './useSocket'; - -export type GroupTimerState = { - /** whether views should display the group timer instead of the event timer */ - isActive: boolean; - /** time remaining in the group, mirrors the semantics of timer.current */ - current: MaybeNumber; - /** time already spent in the group, mirrors the semantics of timer.elapsed */ - elapsed: MaybeNumber; - /** scheduled duration of the group, used as the progress bar target */ - duration: MaybeNumber; -}; - -const inactiveGroupTimer: GroupTimerState = { isActive: false, current: null, elapsed: null, duration: null }; - -/** - * Derives a shared timer for the running group. - * - * The value is the running event timer plus the content still scheduled after it, - * which makes the group behave as if it were a single event containing all its children. - * Deriving it from the event timer (instead of from the clock) means pause, added time, - * overtime, roll and midnight rollovers are all inherited for free. - */ -export function useGroupTimer(): GroupTimerState { - const { group, currentEventId, current } = useGroupTimerData(); - const { data: rundown } = useRundown(); - - return useMemo(() => { - if (!group?.useGroupTimer || currentEventId === null || current === null) { - return inactiveGroupTimer; - } - - // the loaded event could be outside the group while the group data is still settling - if (!group.entries.includes(currentEventId)) { - return inactiveGroupTimer; - } - - const groupCurrent = current + getRemainingGroupTime(group, rundown.entries, currentEventId); - - return { - isActive: true, - current: groupCurrent, - elapsed: Math.max(0, group.duration - groupCurrent), - duration: group.duration, - }; - }, [group, currentEventId, current, rundown.entries]); -} diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts index 015eb9559..55dc0079a 100644 --- a/apps/client/src/common/hooks/useSocket.ts +++ b/apps/client/src/common/hooks/useSocket.ts @@ -154,12 +154,8 @@ export const useTimer = createSelector((state: RuntimeStore) => ({ ...state.timer, })); -/** Runtime data needed to derive the shared group timer, see useGroupTimer */ -export const useGroupTimerData = createSelector((state: RuntimeStore) => ({ - group: state.groupNow, - currentEventId: state.eventNow?.id ?? null, - current: state.timer.current, -})); +/** Shared timer for the running group, null unless the group opted in */ +export const useGroupTimer = createSelector((state: RuntimeStore) => state.groupTimer); export const useNextFlag = createSelector((state: RuntimeStore) => ({ id: state.eventFlag?.id ?? null, diff --git a/apps/client/src/common/utils/__tests__/groupTimer.test.ts b/apps/client/src/common/utils/__tests__/groupTimer.test.ts deleted file mode 100644 index fd8e7f3ae..000000000 --- a/apps/client/src/common/utils/__tests__/groupTimer.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { EntryId, OntimeEntry, OntimeEvent, OntimeGroup } from 'ontime-types'; -import { SupportedEntry } from 'ontime-types'; - -import { getRemainingGroupTime } from '../groupTimer'; - -function makeEvent(id: EntryId, patch: Partial = {}): OntimeEvent { - return { - id, - type: SupportedEntry.Event, - duration: 0, - gap: 0, - skip: false, - parent: 'group', - ...patch, - } as OntimeEvent; -} - -function makeGroup(entries: EntryId[]): OntimeGroup { - return { id: 'group', type: SupportedEntry.Group, entries } as OntimeGroup; -} - -function makeEntries(...entries: OntimeEntry[]): Record { - return Object.fromEntries(entries.map((entry) => [entry.id, entry])); -} - -describe('getRemainingGroupTime()', () => { - it('sums the duration of the events after the current one', () => { - const group = makeGroup(['1', '2', '3']); - const entries = makeEntries( - makeEvent('1', { duration: 10 }), - makeEvent('2', { duration: 20 }), - makeEvent('3', { duration: 30 }), - ); - - expect(getRemainingGroupTime(group, entries, '1')).toBe(50); - expect(getRemainingGroupTime(group, entries, '2')).toBe(30); - }); - - it('accounts for the gaps between events', () => { - const group = makeGroup(['1', '2', '3']); - const entries = makeEntries( - makeEvent('1', { duration: 10 }), - makeEvent('2', { duration: 20, gap: 5 }), - makeEvent('3', { duration: 30, gap: 7 }), - ); - - expect(getRemainingGroupTime(group, entries, '1')).toBe(20 + 5 + 30 + 7); - }); - - it('adds up to the group duration when the first event is loaded', () => { - // mirrors the aggregation the server uses to calculate group.duration - const group = makeGroup(['1', '2', '3']); - const first = makeEvent('1', { duration: 10 }); - const entries = makeEntries(first, makeEvent('2', { duration: 20, gap: 5 }), makeEvent('3', { duration: 30 })); - - const groupDuration = 10 + 20 + 5 + 30; - expect(first.duration + getRemainingGroupTime(group, entries, '1')).toBe(groupDuration); - }); - - it('returns 0 on the last event of the group', () => { - const group = makeGroup(['1', '2']); - const entries = makeEntries(makeEvent('1', { duration: 10 }), makeEvent('2', { duration: 20 })); - - expect(getRemainingGroupTime(group, entries, '2')).toBe(0); - }); - - it('skips entries which are not playable events', () => { - const group = makeGroup(['1', '2', '3', '4']); - const entries = makeEntries( - makeEvent('1', { duration: 10 }), - makeEvent('2', { duration: 20, skip: true }), - { id: '3', type: SupportedEntry.Milestone, parent: 'group' } as OntimeEntry, - makeEvent('4', { duration: 40 }), - ); - - expect(getRemainingGroupTime(group, entries, '1')).toBe(40); - }); - - it('returns 0 when the loaded event is not part of the group', () => { - const group = makeGroup(['1', '2']); - const entries = makeEntries(makeEvent('1', { duration: 10 }), makeEvent('2', { duration: 20 })); - - expect(getRemainingGroupTime(group, entries, 'elsewhere')).toBe(0); - expect(getRemainingGroupTime(group, entries, null)).toBe(0); - }); - - it('tolerates ids which are missing from the rundown', () => { - const group = makeGroup(['1', 'missing', '3']); - const entries = makeEntries(makeEvent('1', { duration: 10 }), makeEvent('3', { duration: 30 })); - - expect(getRemainingGroupTime(group, entries, '1')).toBe(30); - }); -}); diff --git a/apps/client/src/common/utils/groupTimer.ts b/apps/client/src/common/utils/groupTimer.ts deleted file mode 100644 index 9809a18e1..000000000 --- a/apps/client/src/common/utils/groupTimer.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { EntryId, OntimeEntry, OntimeGroup } from 'ontime-types'; -import { isOntimeEvent, isPlayableEvent } from 'ontime-types'; - -/** - * Calculates the amount of playable content remaining in a group - * after (and excluding) the currently loaded event. - * - * The group timer treats the group as if it were a single event, so the displayed - * value is the running event timer plus whatever is still scheduled after it. - * Keeping this relative to the event timer means the group timer inherits pause, - * added time, overtime and roll behaviour without duplicating any of that logic. - * - * The aggregation mirrors the group duration calculated in the server - * (apps/server/src/api-data/rundown/rundown.dao.ts): non playable entries are - * skipped and the gap is accounted for in every entry other than the first. - */ -export function getRemainingGroupTime( - group: OntimeGroup, - entries: Record, - currentEventId: EntryId | null, -): number { - if (currentEventId === null) { - return 0; - } - - const currentIndex = group.entries.indexOf(currentEventId); - if (currentIndex === -1) { - return 0; - } - - let remaining = 0; - - for (let i = currentIndex + 1; i < group.entries.length; i++) { - const entry = entries[group.entries[i]]; - if (!entry || !isOntimeEvent(entry) || !isPlayableEvent(entry)) { - continue; - } - - // the first entry of the group has no gap to account for, - // any other entry could be preceded by idle time - if (i > 0) { - remaining += entry.gap; - } - remaining += entry.duration; - } - - return remaining; -} diff --git a/apps/client/src/features/control/message/TimerPreview.tsx b/apps/client/src/features/control/message/TimerPreview.tsx index 70731ca26..8f35c22ca 100644 --- a/apps/client/src/features/control/message/TimerPreview.tsx +++ b/apps/client/src/features/control/message/TimerPreview.tsx @@ -5,8 +5,7 @@ import { LuArrowDownToLine } from 'react-icons/lu'; import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils'; import Tooltip from '../../../common/components/tooltip/Tooltip'; import useViewSettings from '../../../common/hooks-query/useViewSettings'; -import { useGroupTimer } from '../../../common/hooks/useGroupTimer'; -import { useMessagePreview } from '../../../common/hooks/useSocket'; +import { useGroupTimer, useMessagePreview } from '../../../common/hooks/useSocket'; import { handleLinks } from '../../../common/utils/linkUtils'; import { cx, timerPlaceholder } from '../../../common/utils/styleUtils'; import PipRoot from '../../../views/editor/pip-timer/PipRoot'; @@ -23,7 +22,7 @@ const secondarySourceLabels: Record = { export default function TimerPreview() { const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType } = useMessagePreview(); const { data } = useViewSettings(); - const { isActive: isGroupTimerActive } = useGroupTimer(); + const isGroupTimerActive = useGroupTimer() !== null; const main = (() => { if (showTimerMessage) return 'Message'; diff --git a/apps/client/src/views/editor/pip-timer/PipHost.tsx b/apps/client/src/views/editor/pip-timer/PipHost.tsx index 7b49ec041..a11e914ef 100644 --- a/apps/client/src/views/editor/pip-timer/PipHost.tsx +++ b/apps/client/src/views/editor/pip-timer/PipHost.tsx @@ -1,10 +1,8 @@ import { ErrorBoundary } from '@sentry/react'; -import { QueryClientProvider } from '@tanstack/react-query'; import { createRoot } from 'react-dom/client'; import { CornerPipButton } from '../../../common/components/editor-utils/EditorUtils'; import useViewSettings from '../../../common/hooks-query/useViewSettings'; -import { ontimeQueryClient } from '../../../common/queryClient'; import { PipTimer } from './PipTimer'; export default function PipTimerHost() { @@ -57,10 +55,7 @@ export default function PipTimerHost() { pipRoot.render( - {/* the PiP document is a separate react root, it needs its own provider to reach the query cache */} - - - + , ); }; diff --git a/apps/client/src/views/editor/pip-timer/PipTimer.tsx b/apps/client/src/views/editor/pip-timer/PipTimer.tsx index 7c3f035f6..80f82ba37 100644 --- a/apps/client/src/views/editor/pip-timer/PipTimer.tsx +++ b/apps/client/src/views/editor/pip-timer/PipTimer.tsx @@ -1,9 +1,8 @@ -import { TimerPhase, ViewSettings } from 'ontime-types'; +import { ViewSettings } from 'ontime-types'; import { FitText } from '../../../common/components/fit-text/FitText'; import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar'; -import { useGroupTimer } from '../../../common/hooks/useGroupTimer'; -import { useTimerSocket } from '../../../common/hooks/useSocket'; +import { useGroupTimer, useTimerSocket } from '../../../common/hooks/useSocket'; import { cx } from '../../../common/utils/styleUtils'; import { getFormattedTimer, getTimerByType } from '../../common/viewUtils'; import { @@ -11,9 +10,8 @@ import { getIsPlaying, getSecondaryDisplay, getShowMessage, - getShowModifiers, getShowProgressBar, - getTotalTime, + resolveTimerDisplay, } from '../../timer/timer.utils'; import { getTimerColour } from '../../utils/presentation.utils'; @@ -29,26 +27,23 @@ export function PipTimer({ viewSettings }: PipTimerProps) { // gather modifiers const showOverlay = getShowMessage(message.timer); - const { - showFinished: eventShowFinished, - showWarning: eventShowWarning, - showDanger: eventShowDanger, - } = getShowModifiers(timerTypeNow, countToEndNow, time.phase, false, '', false); - - /** - * warning and danger thresholds belong to the event, they carry no meaning against a group duration. - * overtime is kept, but only once the group itself has run out of time - */ - const showWarning = eventShowWarning && !groupTimer.isActive; - const showDanger = eventShowDanger && !groupTimer.isActive; - const showFinished = eventShowFinished && (!groupTimer.isActive || (groupTimer.current ?? 0) <= 0); + const timerDisplay = resolveTimerDisplay({ + time, + groupTimer, + event: eventNow, + timerType: timerTypeNow, + countToEnd: countToEndNow, + freezeOvertime: false, + freezeMessage: '', + hidePhase: false, + }); + const { showFinished, showWarning, showDanger } = timerDisplay; const isPlaying = getIsPlaying(time.playback); const showProgressBar = getShowProgressBar(timerTypeNow); // gather timer data - const totalTime = groupTimer.isActive ? groupTimer.duration : getTotalTime(time.duration, time.addedTime); - const stageTimer = getTimerByType(false, timerTypeNow, clock, groupTimer.isActive ? groupTimer : time, timerTypeNow); + const stageTimer = getTimerByType(false, timerTypeNow, clock, timerDisplay.source, timerTypeNow); const display = getFormattedTimer(stageTimer, timerTypeNow, 'min', { removeSeconds: false, removeLeadingZero: false, @@ -88,11 +83,11 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
{display}
- {groupTimer.isActive &&
group
} + {timerDisplay.isGroup &&
group
}
{secondaryContent} @@ -103,12 +98,12 @@ export function PipTimer({ viewSettings }: PipTimerProps) { {showProgressBar && ( diff --git a/apps/client/src/views/timer/Timer.tsx b/apps/client/src/views/timer/Timer.tsx index b5eb39f94..d75788e3a 100644 --- a/apps/client/src/views/timer/Timer.tsx +++ b/apps/client/src/views/timer/Timer.tsx @@ -1,4 +1,4 @@ -import { MaybeString, OntimeView, TimerPhase, TimerType } from 'ontime-types'; +import { MaybeString, OntimeView, TimerType } from 'ontime-types'; import { useMemo } from 'react'; import { FitText } from '../../common/components/fit-text/FitText'; @@ -8,8 +8,7 @@ import TitleCard from '../../common/components/title-card/TitleCard'; import ViewLogo from '../../common/components/view-logo/ViewLogo'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import { useAutoTickingClock } from '../../common/hooks/useAutoTickingClock'; -import { useGroupTimer } from '../../common/hooks/useGroupTimer'; -import { useTimerSocket } from '../../common/hooks/useSocket'; +import { useGroupTimer, useTimerSocket } from '../../common/hooks/useSocket'; import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import { cx } from '../../common/utils/styleUtils'; import { formatTime, getDefaultFormat } from '../../common/utils/time'; @@ -26,9 +25,8 @@ import { getSecondaryDisplay, getShowClock, getShowMessage, - getShowModifiers, getShowProgressBar, - getTotalTime, + resolveTimerDisplay, } from './timer.utils'; import { TimerData, useTimerData } from './useTimerData'; @@ -81,20 +79,17 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings, // gather modifiers const viewTimerType = timerType ?? timerTypeNow; const showOverlay = getShowMessage(message.timer); - const { - showEndMessage, - showFinished: eventShowFinished, - showWarning: eventShowWarning, - showDanger: eventShowDanger, - } = getShowModifiers(timerTypeNow, countToEndNow, time.phase, freezeOvertime, freezeMessage, hidePhase); - - /** - * warning and danger thresholds belong to the event, they carry no meaning against a group duration. - * overtime is kept, but only once the group itself has run out of time - */ - const showWarning = eventShowWarning && !groupTimer.isActive; - const showDanger = eventShowDanger && !groupTimer.isActive; - const showFinished = eventShowFinished && (!groupTimer.isActive || (groupTimer.current ?? 0) <= 0); + const timerDisplay = resolveTimerDisplay({ + time, + groupTimer, + event: eventNow, + timerType: timerTypeNow, + countToEnd: countToEndNow, + freezeOvertime, + freezeMessage, + hidePhase, + }); + const { showEndMessage, showFinished, showWarning, showDanger } = timerDisplay; const isPlaying = getIsPlaying(time.playback); const showClock = !hideClock && getShowClock(viewTimerType); const showProgressBar = !hideProgress && getShowProgressBar(viewTimerType); @@ -111,9 +106,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings, ); // gather timer data - const totalTime = groupTimer.isActive ? groupTimer.duration : getTotalTime(time.duration, time.addedTime); - const timerSource = groupTimer.isActive ? groupTimer : time; - const stageTimer = getTimerByType(freezeOvertime, timerTypeNow, clock, timerSource, timerType); + const stageTimer = getTimerByType(freezeOvertime, timerTypeNow, clock, timerDisplay.source, timerType); const display = getFormattedTimer(stageTimer, viewTimerType, localisedMinutes, { removeSeconds: hideTimerSeconds, removeLeadingZero: removeLeadingZeros, @@ -188,12 +181,12 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings, className={cx(['timer', subduePaused && 'timer--paused', showFinished && 'timer--finished'])} style={{ fontSize: `${timerFontSize}vw` }} data-type={viewTimerType} - data-phase={groupTimer.isActive ? TimerPhase.Default : time.phase} + data-phase={timerDisplay.phase} > {display}
)} - {groupTimer.isActive && !showEndMessage &&
group
} + {timerDisplay.isGroup && !showEndMessage &&
group
}
{secondaryContent} @@ -204,12 +197,12 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings, {showProgressBar && ( diff --git a/apps/client/src/views/timer/timer.utils.ts b/apps/client/src/views/timer/timer.utils.ts index 1add32d88..58b19d052 100644 --- a/apps/client/src/views/timer/timer.utils.ts +++ b/apps/client/src/views/timer/timer.utils.ts @@ -1,4 +1,5 @@ import { + GroupTimerState, MaybeNumber, MessageState, OntimeEvent, @@ -6,6 +7,7 @@ import { RundownEntries, TimerMessage, TimerPhase, + TimerState, TimerType, } from 'ontime-types'; import { isPlaybackActive } from 'ontime-utils'; @@ -114,6 +116,57 @@ export function getShowModifiers( }; } +interface ResolveTimerDisplayOptions { + time: TimerState; + /** when present, views show the group instead of the running event */ + groupTimer: GroupTimerState | null; + event: Pick | null; + timerType: TimerType; + countToEnd: boolean; + freezeOvertime: boolean; + freezeMessage: string; + hidePhase: boolean; +} + +/** + * Resolves which timer a view should display, and the modifiers that go with it. + * + * A group has no warning or danger thresholds of its own, so it only ever reports as + * running or overtime. Feeding that phase through `getShowModifiers` means the warning + * and danger states are suppressed as a consequence of what a group is, rather than + * every view having to remember to special case them. + */ +export function resolveTimerDisplay({ + time, + groupTimer, + event, + timerType, + countToEnd, + freezeOvertime, + freezeMessage, + hidePhase, +}: ResolveTimerDisplayOptions) { + const isGroup = groupTimer !== null; + const phase = isGroup + ? groupTimer.current <= 0 + ? TimerPhase.Overtime + : TimerPhase.Default + : time.phase; + + return { + isGroup, + phase, + /** the values to render, shaped for getTimerByType */ + source: isGroup ? groupTimer : time, + /** the target of a progress bar */ + total: isGroup ? groupTimer.duration : getTotalTime(time.duration, time.addedTime), + // thresholds belong to the event, they carry no meaning against a group duration + warning: isGroup ? undefined : event?.timeWarning, + danger: isGroup ? undefined : event?.timeDanger, + ...getShowModifiers(timerType, countToEnd, phase, freezeOvertime, freezeMessage, hidePhase), + }; +} + /** * What, if anything, should be displayed in the secondary field */ diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 29d9d17a6..5b373ea37 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -207,6 +207,7 @@ export const startServer = async (): Promise<{ message: string; serverPort: numb eventStore.init({ clock: state.clock, timer: state.timer, + groupTimer: state.groupTimer, message: { ...runtimeStorePlaceholder.message }, offset: state.offset, rundown: state.rundown, diff --git a/apps/server/src/services/__tests__/timerUtils.test.ts b/apps/server/src/services/__tests__/timerUtils.test.ts index eecc5893a..4d8b6adf3 100644 --- a/apps/server/src/services/__tests__/timerUtils.test.ts +++ b/apps/server/src/services/__tests__/timerUtils.test.ts @@ -1,4 +1,16 @@ -import { EndAction, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types'; +import { + EndAction, + MaybeNumber, + OntimeEvent, + OntimeGroup, + Playback, + Rundown, + SupportedEntry, + TimeOfDay, + TimeStrategy, + TimerPhase, + TimerType, +} from 'ontime-types'; import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisToString } from 'ontime-utils'; import type { RuntimeState } from '../../stores/runtimeState.js'; @@ -7,6 +19,8 @@ import { getCurrent, getElapsed, getExpectedFinish, + getGroupTimer, + getGroupTiming, getRuntimeOffset, getTimerPhase, hasCrossedMidnight, @@ -1452,3 +1466,138 @@ describe('findDay()', () => { expect(findDayOffset(22 * MILLIS_PER_HOUR, 23 * MILLIS_PER_HOUR)).toBe(0); // -> 1 }); }); + +describe('getGroupTiming()', () => { + const makeEvent = (id: string, duration: number, patch: object = {}) => + ({ id, type: SupportedEntry.Event, duration, gap: 0, skip: false, parent: 'group', ...patch }) as OntimeEvent; + + const makeGroup = (entries: string[]) => ({ id: 'group', type: SupportedEntry.Group, entries }) as OntimeGroup; + + const makeEntries = (...entries: OntimeEvent[]) => + Object.fromEntries(entries.map((entry) => [entry.id, entry])) as Rundown['entries']; + + it('splits the scheduled content around the loaded event', () => { + const group = makeGroup(['1', '2', '3']); + const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20), makeEvent('3', 30)); + + expect(getGroupTiming(group, entries, '1')).toStrictEqual({ before: 0, after: 50 }); + expect(getGroupTiming(group, entries, '2')).toStrictEqual({ before: 10, after: 30 }); + expect(getGroupTiming(group, entries, '3')).toStrictEqual({ before: 30, after: 0 }); + }); + + it('accounts for the gaps between events', () => { + const group = makeGroup(['1', '2', '3']); + const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20, { gap: 5 }), makeEvent('3', 30, { gap: 7 })); + + expect(getGroupTiming(group, entries, '1')).toStrictEqual({ before: 0, after: 20 + 5 + 30 + 7 }); + // the gap before the loaded event has already passed, so it counts as time spent + expect(getGroupTiming(group, entries, '2')).toStrictEqual({ before: 10 + 5, after: 30 + 7 }); + }); + + it('splits into values which add up to the group duration', () => { + const group = makeGroup(['1', '2', '3']); + const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20, { gap: 5 }), makeEvent('3', 30)); + const groupDuration = 10 + 20 + 5 + 30; + + for (const id of ['1', '2', '3']) { + const timing = getGroupTiming(group, entries, id)!; + expect(timing.before + entries[id].duration + timing.after).toBe(groupDuration); + } + }); + + it('skips entries which are not playable events', () => { + const group = makeGroup(['1', '2', '3', '4']); + const entries = { + ...makeEntries(makeEvent('1', 10), makeEvent('2', 20, { skip: true }), makeEvent('4', 40)), + '3': { id: '3', type: SupportedEntry.Milestone, parent: 'group' }, + } as Rundown['entries']; + + expect(getGroupTiming(group, entries, '1')).toStrictEqual({ before: 0, after: 40 }); + }); + + it('returns null when the loaded event is not part of the group', () => { + const group = makeGroup(['1', '2']); + const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20)); + + expect(getGroupTiming(group, entries, 'elsewhere')).toBeNull(); + }); +}); + +describe('getGroupTimer()', () => { + const makeState = (patch: { + useGroupTimer?: boolean; + duration?: number; + timing?: { before: number; after: number } | null; + current?: MaybeNumber; + elapsed?: MaybeNumber; + addedTime?: number; + }) => + ({ + groupNow: { duration: patch.duration ?? 100, useGroupTimer: patch.useGroupTimer ?? true }, + _groupTiming: patch.timing === undefined ? { before: 0, after: 0 } : patch.timing, + timer: { + current: patch.current ?? null, + elapsed: patch.elapsed ?? null, + addedTime: patch.addedTime ?? 0, + }, + }) as RuntimeState; + + it('is null when there is no group running', () => { + expect(getGroupTimer({ groupNow: null, _groupTiming: null } as RuntimeState)).toBeNull(); + }); + + it('is null when the group has not opted in', () => { + expect(getGroupTimer(makeState({ useGroupTimer: false, current: 10 }))).toBeNull(); + }); + + it('is null when the loaded event is not part of the group', () => { + expect(getGroupTimer(makeState({ timing: null, current: 10 }))).toBeNull(); + }); + + it('offsets the event timer by the content scheduled around it', () => { + const state = makeState({ duration: 100, timing: { before: 30, after: 50 }, current: 20, elapsed: 0 }); + + expect(getGroupTimer(state)).toStrictEqual({ current: 70, elapsed: 30, duration: 100 }); + }); + + it('keeps elapsed and current adding up to the total', () => { + const state = makeState({ duration: 100, timing: { before: 30, after: 50 }, current: 12, elapsed: 8 }); + const groupTimer = getGroupTimer(state)!; + + expect(groupTimer.elapsed + groupTimer.current).toBe(groupTimer.duration); + }); + + it('grows the total with the time added to the running event', () => { + const state = makeState({ + duration: 100, + timing: { before: 0, after: 0 }, + current: 160, + elapsed: 0, + addedTime: 60, + }); + const groupTimer = getGroupTimer(state)!; + + expect(groupTimer.duration).toBe(160); + // elapsed keeps counting up rather than clamping at zero once time is added + expect(groupTimer.elapsed).toBe(0); + expect(groupTimer.elapsed + groupTimer.current).toBe(groupTimer.duration); + }); + + it('reports elapsed time while the group is in credit from added time', () => { + const state = makeState({ + duration: 100, + timing: { before: 0, after: 0 }, + current: 150, + elapsed: 10, + addedTime: 60, + }); + + expect(getGroupTimer(state)).toStrictEqual({ current: 150, elapsed: 10, duration: 160 }); + }); + + it('goes negative when the group runs into overtime', () => { + const state = makeState({ duration: 100, timing: { before: 90, after: 0 }, current: -15, elapsed: 25 }); + + expect(getGroupTimer(state)?.current).toBe(-15); + }); +}); diff --git a/apps/server/src/services/runtime-service/runtime.service.ts b/apps/server/src/services/runtime-service/runtime.service.ts index ee6d0de58..ed41ab74a 100644 --- a/apps/server/src/services/runtime-service/runtime.service.ts +++ b/apps/server/src/services/runtime-service/runtime.service.ts @@ -709,6 +709,18 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert RuntimeService.previousState.timer = { ...state.timer }; } + /** + * the group timer is the event timer offset by a constant, so it changes exactly when + * the event timer does and can share its throttling. + * entry changes are also considered since loading an event can move us between groups + */ + if (updateTimer || entryChanged) { + if (!deepEqual(RuntimeService.previousState.groupTimer, state.groupTimer)) { + batch.add('groupTimer', state.groupTimer); + RuntimeService.previousState.groupTimer = state.groupTimer ? { ...state.groupTimer } : null; + } + } + /** * clock has changed by a second or more. * or the timer updated so we ensure that the timer and clock ticks are in sync diff --git a/apps/server/src/services/timerUtils.ts b/apps/server/src/services/timerUtils.ts index 8c28a5e8d..613828c8b 100644 --- a/apps/server/src/services/timerUtils.ts +++ b/apps/server/src/services/timerUtils.ts @@ -1,7 +1,18 @@ -import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types'; +import { + Day, + EntryId, + GroupTimerState, + MaybeNumber, + OntimeGroup, + Rundown, + TimeOfDay, + TimerPhase, + isOntimeEvent, + isPlayableEvent, +} from 'ontime-types'; import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils'; -import type { RuntimeState } from '../stores/runtimeState.js'; +import type { GroupTiming, RuntimeState } from '../stores/runtimeState.js'; /** * handle events that span over midnight @@ -231,3 +242,76 @@ export function findDayOffset(plannedStart: number, clock: number): Day { if (distance < -12 * MILLIS_PER_HOUR) return 1 as Day; return 0 as Day; } + +/** + * Splits a group's scheduled content around the loaded event. + * + * The result is stable for as long as the same event is loaded, so it is calculated + * when the group is loaded rather than on every update. + * The aggregation mirrors the group duration calculated in the rundown + * (see rundown.dao.ts): non playable entries are skipped and the gap is + * accounted for in every entry other than the first. + */ +export function getGroupTiming( + group: OntimeGroup, + entries: Rundown['entries'], + currentEventId: EntryId, +): GroupTiming | null { + const currentIndex = group.entries.indexOf(currentEventId); + if (currentIndex === -1) { + return null; + } + + let before = 0; + let after = 0; + + for (let i = 0; i < group.entries.length; i++) { + const entry = entries[group.entries[i]]; + if (!isOntimeEvent(entry) || !isPlayableEvent(entry)) { + continue; + } + + // the first entry of the group has no gap to account for, + // any other entry could be preceded by idle time + const gap = i > 0 ? entry.gap : 0; + + if (i < currentIndex) { + before += gap + entry.duration; + } else if (i === currentIndex) { + // the loaded event contributes its own duration through the event timer, + // but the idle time before it has already passed + before += gap; + } else { + after += gap + entry.duration; + } + } + + return { before, after }; +} + +/** + * Derives the shared group timer from the event timer. + * + * Keeping this relative to the event timer is what makes the group behave as if it were + * a single event containing all its children: pause, added time, overtime, roll and + * midnight rollovers are all inherited instead of being recalculated. + */ +export function getGroupTimer(state: RuntimeState): GroupTimerState | null { + const { groupNow, _groupTiming } = state; + + if (groupNow === null || !groupNow.useGroupTimer || _groupTiming === null) { + return null; + } + + const { current, elapsed, addedTime } = state.timer; + if (current === null) { + return null; + } + + return { + current: current + _groupTiming.after, + elapsed: (elapsed ?? 0) + _groupTiming.before, + // mirrors the total time of an event, which grows with the time added to it + duration: groupNow.duration + addedTime, + }; +} diff --git a/apps/server/src/stores/__mocks__/runtimeState.mocks.ts b/apps/server/src/stores/__mocks__/runtimeState.mocks.ts index eb9905f2b..4fe325d8d 100644 --- a/apps/server/src/stores/__mocks__/runtimeState.mocks.ts +++ b/apps/server/src/stores/__mocks__/runtimeState.mocks.ts @@ -9,6 +9,8 @@ const baseState: RuntimeState = { eventNext: null, eventFlag: null, groupNow: null, + groupTimer: null, + _groupTiming: null, rundown: { selectedEventIndex: null, numEvents: 0, diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index f306b154e..ce05a0736 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -16,6 +16,7 @@ import { TimeOfDay, TimerPhase, TimerState, + GroupTimerState, isOntimeEvent, runtimeStorePlaceholder, } from 'ontime-types'; @@ -39,6 +40,8 @@ import { getCurrent, getElapsed, getExpectedFinish, + getGroupTimer, + getGroupTiming, getRuntimeOffset, getTimerPhase, hasCrossedMidnight, @@ -51,6 +54,12 @@ type ExpectedMetadata = { isLinkedToLoaded: boolean; } | null; +/** scheduled content of the running group, split around the loaded event */ +export type GroupTiming = { + before: number; + after: number; +}; + export type RuntimeState = { clock: TimeOfDay; groupNow: OntimeGroup | null; @@ -59,6 +68,8 @@ export type RuntimeState = { eventFlag: PlayableEvent | null; offset: Offset; timer: TimerState; + /** derived from the timer, only refreshed on the getState() projection */ + groupTimer: GroupTimerState | null; rundown: RundownState; // private properties of the timer calculations _timer: { @@ -72,6 +83,7 @@ export type RuntimeState = { totalDelay: number; // this value comes from rundown service }; _group: ExpectedMetadata; + _groupTiming: GroupTiming | null; _flag: ExpectedMetadata; _end: ExpectedMetadata; _startEpoch: Maybe; @@ -86,6 +98,7 @@ const runtimeState: RuntimeState = { eventFlag: null, offset: { ...runtimeStorePlaceholder.offset }, timer: { ...runtimeStorePlaceholder.timer }, + groupTimer: null, rundown: { ...runtimeStorePlaceholder.rundown }, _timer: { forceFinish: null, @@ -98,6 +111,7 @@ const runtimeState: RuntimeState = { totalDelay: 0, }, _group: null, + _groupTiming: null, _flag: null, _end: null, _startEpoch: null, @@ -115,6 +129,8 @@ export function getState(): Readonly { offset: { ...runtimeState.offset }, rundown: { ...runtimeState.rundown }, timer: { ...runtimeState.timer }, + // derived here so it can never drift from the timer values it is built on + groupTimer: getGroupTimer(runtimeState), _timer: { ...runtimeState._timer }, _rundown: { ...runtimeState._rundown }, }; @@ -156,6 +172,7 @@ export function clearState() { runtimeState.groupNow = null; runtimeState._group = null; + runtimeState._groupTiming = null; runtimeState.rundown.actualStart = null; runtimeState.rundown.selectedEventIndex = null; @@ -897,6 +914,7 @@ export function loadGroupFlagAndEnd( const previousGroup = state.groupNow?.id; state.groupNow = null; state._group = null; + state._groupTiming = null; state.eventFlag = null; state._flag = null; state._end = null; @@ -920,6 +938,11 @@ export function loadGroupFlagAndEnd( state.rundown.actualGroupStart = null; } + // the split is stable while the same event is loaded, so we only calculate it here + if (state.groupNow !== null) { + state._groupTiming = getGroupTiming(state.groupNow, rundown.entries, state.eventNow.id); + } + // if we don't have a any flags in the rundown then no need to look for it let foundFlag = !flagsPresent; // if we don't have a last event for the group there is no need to find its end time diff --git a/packages/types/src/definitions/runtime/RuntimeStore.ts b/packages/types/src/definitions/runtime/RuntimeStore.ts index a5e899abd..92edcd46a 100644 --- a/packages/types/src/definitions/runtime/RuntimeStore.ts +++ b/packages/types/src/definitions/runtime/RuntimeStore.ts @@ -17,6 +17,7 @@ export const runtimeStorePlaceholder: Readonly = { secondaryTimer: null, // change on every update startedAt: null, // change can only be initiated by user }, + groupTimer: null, // derived from the timer, null unless the running group opted in message: { timer: { text: '', diff --git a/packages/types/src/definitions/runtime/RuntimeStore.type.ts b/packages/types/src/definitions/runtime/RuntimeStore.type.ts index d2517c3f6..2db088dda 100644 --- a/packages/types/src/definitions/runtime/RuntimeStore.type.ts +++ b/packages/types/src/definitions/runtime/RuntimeStore.type.ts @@ -3,12 +3,14 @@ import type { SimpleTimerState } from './AuxTimer.type.js'; import type { MessageState } from './MessageControl.type.js'; import type { Offset } from './Offset.type.js'; import type { RundownState } from './RundownState.type.js'; -import type { TimerState } from './TimerState.type.js'; +import type { GroupTimerState, TimerState } from './TimerState.type.js'; export type RuntimeStore = { // timer data clock: number; timer: TimerState; + /** shared timer for the running group, null unless the group opted in */ + groupTimer: GroupTimerState | null; // messages service message: MessageState; diff --git a/packages/types/src/definitions/runtime/TimerState.type.ts b/packages/types/src/definitions/runtime/TimerState.type.ts index 626144876..1d7cd8ff7 100644 --- a/packages/types/src/definitions/runtime/TimerState.type.ts +++ b/packages/types/src/definitions/runtime/TimerState.type.ts @@ -11,6 +11,22 @@ export enum TimerPhase { Pending = 'pending', } +/** + * Timer for the running group, present when the group opted into a shared timer. + * + * The group is treated as a single event containing all its children: the values are + * derived from the running event timer plus the content scheduled around it, so pause, + * added time, overtime and roll are inherited from the event timer rather than recalculated. + */ +export type GroupTimerState = { + /** Time remaining in the group */ + current: number; + /** Time elapsed since the group started */ + elapsed: number; + /** Total time in the group, includes time added to the running event */ + duration: number; +}; + /** * Gathers the current running timer state */ diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 77e98fd91..9ed8dea47 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -119,7 +119,7 @@ export type { Offset } from './definitions/runtime/Offset.type.js'; export { OffsetMode } from './definitions/runtime/Offset.type.js'; export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js'; export { runtimeStorePlaceholder } from './definitions/runtime/RuntimeStore.js'; -export { type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js'; +export { type GroupTimerState, type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js'; // ---> Extra Timer export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './definitions/runtime/AuxTimer.type.js';