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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kniqs443KUNCRABwVJwT7K
This commit is contained in:
Claude
2026-07-26 06:24:15 +00:00
parent 5cf36f049a
commit 636b2e7c3e
22 changed files with 378 additions and 32 deletions
@@ -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<string, string> = {
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() {
>
<LuArrowDownToLine />
</Tooltip>
<Tooltip
text={isGroupTimerActive ? 'Views are showing the shared group timer' : 'Views are showing the event timer'}
render={<span />}
className={style.statusIcon}
data-active={isGroupTimerActive}
>
<IoFolderOutline />
</Tooltip>
</div>
</div>
);
@@ -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}
/>
</div>
<div>
<Tooltip
text='Timer views count the scheduled duration of the whole group instead of the running event'
render={<Editor.Label htmlFor='useGroupTimer' />}
>
Group timer
</Tooltip>
<Editor.Label className={style.switchLabel}>
<Switch
id='useGroupTimer'
checked={group.useGroupTimer}
onCheckedChange={(value) => updateEntry({ id: group.id, useGroupTimer: value })}
/>
{group.useGroupTimer ? 'On' : 'Off'}
</Editor.Label>
</div>
</div>
<div className={style.column}>
@@ -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 && <IoLockClosed className={style.lockIcon} />}
</div>
</div>
{data.useGroupTimer && (
<div className={style.metaEntry}>
<Tooltip text='Views show a shared timer for this group instead of the event timer'>
<Tag>Group timer</Tag>
</Tooltip>
</div>
)}
</div>
</div>
</div>