mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-16 04:43:35 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user