From 636b2e7c3eae1f7055736bf1e307a79111f139b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 06:24:15 +0000 Subject: [PATCH] feat(rundown): add opt-in shared group timer Groups can now opt into a shared timer, so stage outputs show how much time the whole block has left instead of only the running event. The value is derived in the client from 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. Keeping it relative to the event timer (rather than recalculating from the clock) means pause, added time, overtime, roll and midnight rollovers are all inherited, and moving between events inside a group cannot introduce a discontinuity. The remainder aggregation mirrors the server side group duration calculation: non playable entries are skipped and gaps are accounted for. Warning and danger thresholds belong to the event and carry no meaning against a group duration, so they are suppressed while the group timer is showing. Overtime is kept, but only once the group itself has run out of time. - add OntimeGroup.useGroupTimer, defaulting to false - show the group timer in the timer view and the PiP timer - indicate the override in the timer view, PiP, group header and timer preview - add a switch to the group editor The PiP window mounts its own react root, which had no query client and could therefore not read the rundown. It is now wrapped in a provider, which also means its view settings are no longer frozen at the moment the window opened. 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 | 7 ++ .../common/utils/__tests__/groupTimer.test.ts | 93 +++++++++++++++++++ apps/client/src/common/utils/groupTimer.ts | 48 ++++++++++ .../features/control/message/TimerPreview.tsx | 12 ++- .../rundown/entry-editor/GroupEditor.tsx | 18 ++++ .../rundown/rundown-group/RundownGroup.tsx | 8 ++ .../src/views/editor/pip-timer/PipHost.tsx | 7 +- .../src/views/editor/pip-timer/PipTimer.scss | 12 +++ .../src/views/editor/pip-timer/PipTimer.tsx | 39 +++++--- apps/client/src/views/timer/Timer.scss | 12 +++ apps/client/src/views/timer/Timer.tsx | 41 +++++--- .../api-data/db/migration/db.migration.v3.ts | 1 + .../api-data/db/migration/migration.test.ts | 2 + .../rundown/__tests__/rundown.parser.test.ts | 17 ++++ .../rundown/__tests__/rundown.utils.test.ts | 23 +++++ .../src/api-data/rundown/rundown.utils.ts | 2 + apps/server/src/api-mcp/mcp.schema.ts | 1 + apps/server/src/models/demoRundowns.ts | 11 +++ .../types/src/definitions/core/OntimeEntry.ts | 2 + .../src/rundown-utils/entryDefinitions.ts | 1 + .../utils/src/rundown-utils/entryUtils.ts | 1 + 22 files changed, 378 insertions(+), 32 deletions(-) create mode 100644 apps/client/src/common/hooks/useGroupTimer.ts create mode 100644 apps/client/src/common/utils/__tests__/groupTimer.test.ts create 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 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,