diff --git a/apps/client/src/common/hooks/useGroupTimer.ts b/apps/client/src/common/hooks/useGroupTimer.ts new file mode 100644 index 000000000..c7789e896 --- /dev/null +++ b/apps/client/src/common/hooks/useGroupTimer.ts @@ -0,0 +1,52 @@ +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 97f9b9448..015eb9559 100644 --- a/apps/client/src/common/hooks/useSocket.ts +++ b/apps/client/src/common/hooks/useSocket.ts @@ -154,6 +154,13 @@ 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, +})); + export const useNextFlag = createSelector((state: RuntimeStore) => ({ id: state.eventFlag?.id ?? null, expectedStart: state.offset.expectedFlagStart, diff --git a/apps/client/src/common/utils/__tests__/groupTimer.test.ts b/apps/client/src/common/utils/__tests__/groupTimer.test.ts new file mode 100644 index 000000000..fd8e7f3ae --- /dev/null +++ b/apps/client/src/common/utils/__tests__/groupTimer.test.ts @@ -0,0 +1,93 @@ +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 new file mode 100644 index 000000000..9809a18e1 --- /dev/null +++ b/apps/client/src/common/utils/groupTimer.ts @@ -0,0 +1,48 @@ +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 62c7e6709..70731ca26 100644 --- a/apps/client/src/features/control/message/TimerPreview.tsx +++ b/apps/client/src/features/control/message/TimerPreview.tsx @@ -1,10 +1,11 @@ import { TimerPhase, TimerType } from 'ontime-types'; -import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5'; +import { IoArrowDown, IoArrowUp, IoBan, IoFolderOutline, IoTime } from 'react-icons/io5'; 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 { handleLinks } from '../../../common/utils/linkUtils'; import { cx, timerPlaceholder } from '../../../common/utils/styleUtils'; @@ -22,6 +23,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 main = (() => { if (showTimerMessage) return 'Message'; @@ -105,6 +107,14 @@ export default function TimerPreview() { > + } + className={style.statusIcon} + data-active={isGroupTimerActive} + > + + ); diff --git a/apps/client/src/features/rundown/entry-editor/GroupEditor.tsx b/apps/client/src/features/rundown/entry-editor/GroupEditor.tsx index f1508cd93..9000999a6 100644 --- a/apps/client/src/features/rundown/entry-editor/GroupEditor.tsx +++ b/apps/client/src/features/rundown/entry-editor/GroupEditor.tsx @@ -5,6 +5,8 @@ import { useCallback } from 'react'; import * as Editor from '../../../common/components/editor-utils/EditorUtils'; import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect'; import AppLink from '../../../common/components/link/app-link/AppLink'; +import Switch from '../../../common/components/switch/Switch'; +import Tooltip from '../../../common/components/tooltip/Tooltip'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import useCustomFields from '../../../common/hooks-query/useCustomFields'; import { getOffsetState } from '../../../common/utils/offset'; @@ -95,6 +97,22 @@ export default function GroupEditor({ group }: GroupEditorProps) { submitHandler={handleSubmit} /> +
+ } + > + Group timer + + + updateEntry({ id: group.id, useGroupTimer: value })} + /> + {group.useGroupTimer ? 'On' : 'Off'} + +
diff --git a/apps/client/src/features/rundown/rundown-group/RundownGroup.tsx b/apps/client/src/features/rundown/rundown-group/RundownGroup.tsx index 69cc3f0ce..784761385 100644 --- a/apps/client/src/features/rundown/rundown-group/RundownGroup.tsx +++ b/apps/client/src/features/rundown/rundown-group/RundownGroup.tsx @@ -15,6 +15,7 @@ import { import IconButton from '../../../common/components/buttons/IconButton'; import Tag from '../../../common/components/tag/Tag'; +import Tooltip from '../../../common/components/tooltip/Tooltip'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useEntryCopy } from '../../../common/stores/entryCopyStore'; @@ -189,6 +190,13 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }: {data.targetDuration !== null && }
+ {data.useGroupTimer && ( +
+ + Group timer + +
+ )} diff --git a/apps/client/src/views/editor/pip-timer/PipHost.tsx b/apps/client/src/views/editor/pip-timer/PipHost.tsx index a11e914ef..7b49ec041 100644 --- a/apps/client/src/views/editor/pip-timer/PipHost.tsx +++ b/apps/client/src/views/editor/pip-timer/PipHost.tsx @@ -1,8 +1,10 @@ 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() { @@ -55,7 +57,10 @@ 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.scss b/apps/client/src/views/editor/pip-timer/PipTimer.scss index 0cdb6eefd..c16dd922a 100644 --- a/apps/client/src/views/editor/pip-timer/PipTimer.scss +++ b/apps/client/src/views/editor/pip-timer/PipTimer.scss @@ -68,6 +68,18 @@ opacity: 0; } } + + // signals that the displayed timer belongs to the group, not to the running event + .group-indicator { + text-align: center; + text-transform: uppercase; + letter-spacing: 0.25em; + font-weight: 600; + font-size: 2.5vw; + line-height: 1; + opacity: 0.7; + color: var(--timer-colour, $ui-white); + } } .secondary { diff --git a/apps/client/src/views/editor/pip-timer/PipTimer.tsx b/apps/client/src/views/editor/pip-timer/PipTimer.tsx index 20bbd7e8b..7c3f035f6 100644 --- a/apps/client/src/views/editor/pip-timer/PipTimer.tsx +++ b/apps/client/src/views/editor/pip-timer/PipTimer.tsx @@ -1,7 +1,8 @@ -import { ViewSettings } from 'ontime-types'; +import { TimerPhase, 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 { cx } from '../../../common/utils/styleUtils'; import { getFormattedTimer, getTimerByType } from '../../common/viewUtils'; @@ -24,23 +25,30 @@ interface PipTimerProps { export function PipTimer({ viewSettings }: PipTimerProps) { const { eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket(); + const groupTimer = useGroupTimer(); // gather modifiers const showOverlay = getShowMessage(message.timer); - const { showFinished, showWarning, showDanger } = getShowModifiers( - timerTypeNow, - countToEndNow, - time.phase, - false, - '', - false, - ); + 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 isPlaying = getIsPlaying(time.playback); const showProgressBar = getShowProgressBar(timerTypeNow); // gather timer data - const totalTime = getTotalTime(time.duration, time.addedTime); - const stageTimer = getTimerByType(false, timerTypeNow, clock, time, timerTypeNow); + const totalTime = groupTimer.isActive ? groupTimer.duration : getTotalTime(time.duration, time.addedTime); + const stageTimer = getTimerByType(false, timerTypeNow, clock, groupTimer.isActive ? groupTimer : time, timerTypeNow); const display = getFormattedTimer(stageTimer, timerTypeNow, 'min', { removeSeconds: false, removeLeadingZero: false, @@ -80,10 +88,11 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
{display}
+ {groupTimer.isActive &&
group
}
{secondaryContent} @@ -94,12 +103,12 @@ export function PipTimer({ viewSettings }: PipTimerProps) { {showProgressBar && ( diff --git a/apps/client/src/views/timer/Timer.scss b/apps/client/src/views/timer/Timer.scss index 7e94b7c66..c6e2a2f3f 100644 --- a/apps/client/src/views/timer/Timer.scss +++ b/apps/client/src/views/timer/Timer.scss @@ -131,6 +131,18 @@ opacity: 0; } } + + // signals that the displayed timer belongs to the group, not to the running event + .group-indicator { + text-align: center; + text-transform: uppercase; + letter-spacing: 0.25em; + font-weight: 600; + font-size: 1.5vw; + line-height: 1; + opacity: 0.7; + color: var(--timer-colour, var(--timer-color-override, $ui-white)); + } } .secondary { diff --git a/apps/client/src/views/timer/Timer.tsx b/apps/client/src/views/timer/Timer.tsx index 3ae139d26..b5eb39f94 100644 --- a/apps/client/src/views/timer/Timer.tsx +++ b/apps/client/src/views/timer/Timer.tsx @@ -1,4 +1,4 @@ -import { MaybeString, OntimeView, TimerType } from 'ontime-types'; +import { MaybeString, OntimeView, TimerPhase, TimerType } from 'ontime-types'; import { useMemo } from 'react'; import { FitText } from '../../common/components/fit-text/FitText'; @@ -8,6 +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 { useWindowTitle } from '../../common/hooks/useWindowTitle'; import { cx } from '../../common/utils/styleUtils'; @@ -72,20 +73,28 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings, timeformat, } = useTimerOptions(); + const groupTimer = useGroupTimer(); + const { getLocalizedString } = useTranslation(); const localisedMinutes = getLocalizedString('common.minutes'); // gather modifiers const viewTimerType = timerType ?? timerTypeNow; const showOverlay = getShowMessage(message.timer); - const { showEndMessage, showFinished, showWarning, showDanger } = getShowModifiers( - timerTypeNow, - countToEndNow, - time.phase, - freezeOvertime, - freezeMessage, - hidePhase, - ); + 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 isPlaying = getIsPlaying(time.playback); const showClock = !hideClock && getShowClock(viewTimerType); const showProgressBar = !hideProgress && getShowProgressBar(viewTimerType); @@ -102,8 +111,9 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings, ); // gather timer data - const totalTime = getTotalTime(time.duration, time.addedTime); - const stageTimer = getTimerByType(freezeOvertime, timerTypeNow, clock, time, timerType); + 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 display = getFormattedTimer(stageTimer, viewTimerType, localisedMinutes, { removeSeconds: hideTimerSeconds, removeLeadingZero: removeLeadingZeros, @@ -178,11 +188,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={time.phase} + data-phase={groupTimer.isActive ? TimerPhase.Default : time.phase} > {display}
)} + {groupTimer.isActive && !showEndMessage &&
group
}
{secondaryContent} @@ -193,12 +204,12 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings, {showProgressBar && ( diff --git a/apps/server/src/api-data/db/migration/db.migration.v3.ts b/apps/server/src/api-data/db/migration/db.migration.v3.ts index 4f8d43794..994363a5a 100644 --- a/apps/server/src/api-data/db/migration/db.migration.v3.ts +++ b/apps/server/src/api-data/db/migration/db.migration.v3.ts @@ -411,6 +411,7 @@ export function migrateRundown( targetDuration: null, colour: '', //leave default colour custom: {}, // leave empty + useGroupTimer: false, // opt-in feature, off for migrated projects // !==== RUNTIME METADATA ====! // revision: -1, timeStart: null, diff --git a/apps/server/src/api-data/db/migration/migration.test.ts b/apps/server/src/api-data/db/migration/migration.test.ts index 77c1b775b..262141b89 100644 --- a/apps/server/src/api-data/db/migration/migration.test.ts +++ b/apps/server/src/api-data/db/migration/migration.test.ts @@ -316,6 +316,7 @@ describe('v3 to v4', () => { targetDuration: null, timeEnd: null, timeStart: null, + useGroupTimer: false, }, event2: { id: 'event2', @@ -392,6 +393,7 @@ describe('v3 to v4', () => { targetDuration: null, timeEnd: null, timeStart: null, + useGroupTimer: false, }, delay: { type: SupportedEntry.Delay, diff --git a/apps/server/src/api-data/rundown/__tests__/rundown.parser.test.ts b/apps/server/src/api-data/rundown/__tests__/rundown.parser.test.ts index 04cc2b7f2..7f19fda74 100644 --- a/apps/server/src/api-data/rundown/__tests__/rundown.parser.test.ts +++ b/apps/server/src/api-data/rundown/__tests__/rundown.parser.test.ts @@ -301,6 +301,23 @@ describe('parseRundown()', () => { expect(parsedRundown.entries.group).toMatchObject({ entries: ['1', '2'] }); expect(Object.keys(parsedRundown.entries).length).toEqual(3); }); + + it('normalises groups from projects made before the group timer existed', () => { + const rundown = { + id: 'test', + title: '', + order: ['group'], + flatOrder: ['group'], + entries: { + // a group as it would have been persisted by a previous version + group: { id: 'group', type: SupportedEntry.Group, title: 'legacy', entries: [] }, + }, + revision: 1, + } as unknown as Rundown; + + const parsedRundown = parseRundown(rundown, {}); + expect(parsedRundown.entries.group).toMatchObject({ id: 'group', useGroupTimer: false }); + }); }); describe('sanitiseCustomFields()', () => { diff --git a/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts b/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts index faae100e0..693169437 100644 --- a/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts +++ b/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts @@ -15,6 +15,7 @@ import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } fr import { parseRundown } from '../rundown.parser.js'; import { calculateDayOffset, + createGroupPatch, deleteById, doesInvalidateMetadata, getIntegerAndFraction, @@ -610,3 +611,25 @@ describe('isLoadedPlayable()', () => { expect(isLoadedPlayable('keynote', rundown)).toBe(false); }); }); + +describe('createGroupPatch()', () => { + it('preserves useGroupTimer when it is not part of the patch', () => { + const original = makeOntimeGroup({ id: 'group', useGroupTimer: true }); + const patched = createGroupPatch(original, { title: 'Morning sessions' }); + + expect(patched.title).toBe('Morning sessions'); + expect(patched.useGroupTimer).toBe(true); + }); + + it('updates useGroupTimer when it is part of the patch', () => { + const original = makeOntimeGroup({ id: 'group', useGroupTimer: false }); + expect(createGroupPatch(original, { useGroupTimer: true }).useGroupTimer).toBe(true); + expect(createGroupPatch(original, { useGroupTimer: false }).useGroupTimer).toBe(false); + }); + + it('ignores non boolean values for useGroupTimer', () => { + const original = makeOntimeGroup({ id: 'group', useGroupTimer: true }); + // @ts-expect-error -- testing a value coming from an unvalidated request + expect(createGroupPatch(original, { useGroupTimer: 'nope' }).useGroupTimer).toBe(true); + }); +}); diff --git a/apps/server/src/api-data/rundown/rundown.utils.ts b/apps/server/src/api-data/rundown/rundown.utils.ts index 01b256cd7..fe9b8621e 100644 --- a/apps/server/src/api-data/rundown/rundown.utils.ts +++ b/apps/server/src/api-data/rundown/rundown.utils.ts @@ -179,6 +179,8 @@ export function createGroupPatch(originalGroup: OntimeGroup, patchGroup: Partial entries: patchGroup.entries ?? originalGroup.entries, targetDuration: maybeTargetDuration(), colour: makeString(patchGroup.colour, originalGroup.colour), + useGroupTimer: + typeof patchGroup.useGroupTimer === 'boolean' ? patchGroup.useGroupTimer : originalGroup.useGroupTimer, revision: originalGroup.revision, timeStart: originalGroup.timeStart, timeEnd: originalGroup.timeEnd, diff --git a/apps/server/src/api-mcp/mcp.schema.ts b/apps/server/src/api-mcp/mcp.schema.ts index a3303cfd8..52e190876 100644 --- a/apps/server/src/api-mcp/mcp.schema.ts +++ b/apps/server/src/api-mcp/mcp.schema.ts @@ -147,6 +147,7 @@ There are four entry types discriminated by \`type\`: note: string entries: EntryId[] targetDuration: number | null + useGroupTimer: boolean // views show a shared timer for the whole group custom: { [key: string]: string } timeStart: number | null // calculated from nested entries (runtime) timeEnd: number | null // calculated from nested entries (runtime) diff --git a/apps/server/src/models/demoRundowns.ts b/apps/server/src/models/demoRundowns.ts index 3f1a77744..079825ed6 100644 --- a/apps/server/src/models/demoRundowns.ts +++ b/apps/server/src/models/demoRundowns.ts @@ -39,6 +39,7 @@ export const stageRundown: Rundown = { note: '', entries: ['9bf60f', 'bf71a2', 'c2697f', 'fa593e', 'a8b0b3'], targetDuration: null, + useGroupTimer: false, colour: '#339E4E', custom: {}, revision: 0, @@ -172,6 +173,7 @@ export const stageRundown: Rundown = { note: '', entries: ['0aaa7d'], targetDuration: null, + useGroupTimer: false, colour: '#3E75E8', custom: {}, revision: 0, @@ -219,6 +221,7 @@ export const stageRundown: Rundown = { note: '', entries: ['02afca', '75ce86', 'e10ed9', '07df89'], targetDuration: null, + useGroupTimer: false, colour: '#339E4E', custom: {}, revision: 0, @@ -360,6 +363,7 @@ export const backstageRundown: Rundown = { note: '', entries: ['bs0101', 'bs0102', 'bs0103', 'bs0104'], targetDuration: null, + useGroupTimer: false, colour: '#A790F5', custom: {}, revision: 0, @@ -476,6 +480,7 @@ export const backstageRundown: Rundown = { note: '', entries: ['bs0201', 'bs0202', 'bs0203', 'bs0204'], targetDuration: null, + useGroupTimer: false, colour: '#339E4E', custom: {}, revision: 0, @@ -588,6 +593,7 @@ export const backstageRundown: Rundown = { note: '', entries: ['bs0301'], targetDuration: null, + useGroupTimer: false, colour: '#3E75E8', custom: {}, revision: 0, @@ -634,6 +640,7 @@ export const backstageRundown: Rundown = { note: '', entries: ['bs0401', 'bs0402', 'bs0403', 'bs0404'], targetDuration: null, + useGroupTimer: false, colour: '#339E4E', custom: {}, revision: 0, @@ -768,6 +775,7 @@ export const broadcastRundown: Rundown = { note: '', entries: ['br0101', 'br0102', 'br0103'], targetDuration: null, + useGroupTimer: false, colour: '#ED3333', custom: {}, revision: 0, @@ -851,6 +859,7 @@ export const broadcastRundown: Rundown = { note: '', entries: ['br0201', 'br0202', 'br0203', 'br0204'], targetDuration: null, + useGroupTimer: false, colour: '#339E4E', custom: {}, revision: 0, @@ -959,6 +968,7 @@ export const broadcastRundown: Rundown = { note: '', entries: ['br0301', 'br0302'], targetDuration: null, + useGroupTimer: false, colour: '#3E75E8', custom: {}, revision: 0, @@ -1029,6 +1039,7 @@ export const broadcastRundown: Rundown = { note: '', entries: ['br0401', 'br0402', 'br0403'], targetDuration: null, + useGroupTimer: false, colour: '#339E4E', custom: {}, revision: 0, diff --git a/packages/types/src/definitions/core/OntimeEntry.ts b/packages/types/src/definitions/core/OntimeEntry.ts index 7d87e017d..c8effb45a 100644 --- a/packages/types/src/definitions/core/OntimeEntry.ts +++ b/packages/types/src/definitions/core/OntimeEntry.ts @@ -46,6 +46,8 @@ export type OntimeGroup = OntimeBaseEvent & { targetDuration: MaybeNumber; colour: string; custom: EntryCustomFields; + /** whether views should display a shared timer for the group instead of the event timer */ + useGroupTimer: boolean; // !==== RUNTIME METADATA ====! // revision: number; timeStart: MaybeNumber; // calculated at runtime diff --git a/packages/utils/src/rundown-utils/entryDefinitions.ts b/packages/utils/src/rundown-utils/entryDefinitions.ts index 62efe87e7..a5aa90504 100644 --- a/packages/utils/src/rundown-utils/entryDefinitions.ts +++ b/packages/utils/src/rundown-utils/entryDefinitions.ts @@ -54,6 +54,7 @@ export const group: Omit = { targetDuration: null, colour: '', custom: {}, + useGroupTimer: false, // !==== RUNTIME METADATA ====! // revision: 0, // calculated at runtime timeStart: null, // calculated at runtime diff --git a/packages/utils/src/rundown-utils/entryUtils.ts b/packages/utils/src/rundown-utils/entryUtils.ts index 4e73eaea0..61f9be5bb 100644 --- a/packages/utils/src/rundown-utils/entryUtils.ts +++ b/packages/utils/src/rundown-utils/entryUtils.ts @@ -69,6 +69,7 @@ export function createGroup(patch?: Partial): OntimeGroup { targetDuration: patch.targetDuration ?? null, colour: makeString(patch.colour, ''), custom: patch.custom ?? {}, + useGroupTimer: typeof patch.useGroupTimer === 'boolean' ? patch.useGroupTimer : false, revision: 0, timeStart: null, timeEnd: null,