feat: count to group completion

This commit is contained in:
Carlos Valente
2026-09-05 08:36:57 +02:00
parent 9ec12f4927
commit 9d2c86d6be
39 changed files with 715 additions and 70 deletions
@@ -417,7 +417,7 @@ export function migrateRundown(
timeEnd: null,
duration: 0,
isFirstLinked: false,
});
} as unknown as OntimeEntry);
} else if (entry.type === 'delay') {
append({ id: entry.id, type: SupportedEntry.Delay, duration: entry.duration, parent });
}
@@ -1,4 +1,4 @@
import { CustomFields, OntimeEvent, OntimeGroup, Rundown, SupportedEntry } from 'ontime-types';
import { CustomFields, OntimeEvent, OntimeGroup, Rundown, SupportedEntry, TimerType } from 'ontime-types';
import { makeNewRundown } from '../../../models/dataModel.js';
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone } from '../__mocks__/rundown.mocks.js';
@@ -276,7 +276,13 @@ describe('parseRundown()', () => {
expect(parsedRundown.order).toStrictEqual(['group']);
expect(parsedRundown.flatOrder).toStrictEqual(['group', '1', '2']);
expect(parsedRundown.entries).toMatchObject({
group: { id: 'group', type: SupportedEntry.Group, entries: ['1', '2'] },
group: {
id: 'group',
type: SupportedEntry.Group,
entries: ['1', '2'],
useGroupTimer: false,
timerType: TimerType.CountDown,
},
'1': { id: '1', type: SupportedEntry.Event },
'2': { id: '2', type: SupportedEntry.Milestone },
});
@@ -25,6 +25,7 @@ import { parseRundown } from '../rundown.parser.js';
import {
calculateDayOffset,
cloneEntryData,
createGroupPatch,
deleteById,
doesInvalidateMetadata,
getIntegerAndFraction,
@@ -36,6 +37,25 @@ import {
} from '../rundown.utils.js';
describe('test event validator', () => {
it('creates groups with the shared timer disabled by default', () => {
expect(createGroup({ id: 'group' })).toMatchObject({
useGroupTimer: false,
timerType: TimerType.CountDown,
});
});
it('limits group timers to count down and count up', () => {
expect(createGroup({ timerType: TimerType.CountUp }).timerType).toBe(TimerType.CountUp);
expect(createGroup({ timerType: TimerType.Clock }).timerType).toBe(TimerType.CountDown);
});
it('rejects non-boolean group timer updates', () => {
const group = createGroup({ useGroupTimer: false });
const updated = createGroupPatch(group, { useGroupTimer: 'true' as never });
expect(updated.useGroupTimer).toBe(false);
});
it('validates a good object', () => {
const event = {
title: 'test',
@@ -33,6 +33,7 @@ import {
makeString,
maxDuration,
validateEndAction,
validateGroupTimerType,
validateTimerType,
validateTimes,
} from 'ontime-utils';
@@ -180,6 +181,9 @@ export function createGroupPatch(originalGroup: OntimeGroup, patchGroup: Partial
note: makeString(patchGroup.note, originalGroup.note),
entries: patchGroup.entries ?? originalGroup.entries,
targetDuration: maybeTargetDuration(),
useGroupTimer:
typeof patchGroup.useGroupTimer === 'boolean' ? patchGroup.useGroupTimer : originalGroup.useGroupTimer,
timerType: validateGroupTimerType(patchGroup.timerType, originalGroup.timerType),
colour: makeString(patchGroup.colour, originalGroup.colour),
revision: originalGroup.revision,
timeStart: originalGroup.timeStart,
+1
View File
@@ -206,6 +206,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,
+22
View File
@@ -39,6 +39,8 @@ export const stageRundown: Rundown = {
note: '',
entries: ['9bf60f', 'bf71a2', 'c2697f', 'fa593e', 'a8b0b3'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -172,6 +174,8 @@ export const stageRundown: Rundown = {
note: '',
entries: ['0aaa7d'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#3E75E8',
custom: {},
revision: 0,
@@ -219,6 +223,8 @@ export const stageRundown: Rundown = {
note: '',
entries: ['02afca', '75ce86', 'e10ed9', '07df89'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -360,6 +366,8 @@ export const backstageRundown: Rundown = {
note: '',
entries: ['bs0101', 'bs0102', 'bs0103', 'bs0104'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#A790F5',
custom: {},
revision: 0,
@@ -476,6 +484,8 @@ export const backstageRundown: Rundown = {
note: '',
entries: ['bs0201', 'bs0202', 'bs0203', 'bs0204'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -588,6 +598,8 @@ export const backstageRundown: Rundown = {
note: '',
entries: ['bs0301'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#3E75E8',
custom: {},
revision: 0,
@@ -634,6 +646,8 @@ export const backstageRundown: Rundown = {
note: '',
entries: ['bs0401', 'bs0402', 'bs0403', 'bs0404'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -768,6 +782,8 @@ export const broadcastRundown: Rundown = {
note: '',
entries: ['br0101', 'br0102', 'br0103'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#ED3333',
custom: {},
revision: 0,
@@ -851,6 +867,8 @@ export const broadcastRundown: Rundown = {
note: '',
entries: ['br0201', 'br0202', 'br0203', 'br0204'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -959,6 +977,8 @@ export const broadcastRundown: Rundown = {
note: '',
entries: ['br0301', 'br0302'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#3E75E8',
custom: {},
revision: 0,
@@ -1029,6 +1049,8 @@ export const broadcastRundown: Rundown = {
note: '',
entries: ['br0401', 'br0402', 'br0403'],
targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -4,6 +4,7 @@ import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisT
import type { RuntimeState } from '../../stores/runtimeState.js';
import {
findDayOffset,
getGroupTimer,
getCurrent,
getElapsed,
getExpectedFinish,
@@ -16,6 +17,91 @@ import {
const asTimeOfDay = (value: number): RuntimeState['clock'] => value as RuntimeState['clock'];
describe('getGroupTimer()', () => {
const makeState = (patch: Partial<RuntimeState> = {}) =>
({
clock: asTimeOfDay(10_000),
groupNow: {
duration: 20_000,
},
rundown: {
actualGroupStart: 5_000,
},
timer: {
addedTime: 4_000,
current: 1_000,
elapsed: 99_000,
phase: TimerPhase.Danger,
playback: Playback.Play,
},
...patch,
}) as RuntimeState;
it('returns null outside a group', () => {
expect(getGroupTimer(makeState({ groupNow: null }))).toBeNull();
});
it('derives elapsed and remaining time from the group start and duration', () => {
expect(getGroupTimer(makeState())).toMatchObject({
addedTime: 0,
current: 15_000,
duration: 20_000,
elapsed: 5_000,
expectedFinish: null,
phase: TimerPhase.Default,
playback: Playback.Play,
secondaryTimer: null,
startedAt: 5_000,
});
});
it('does not inherit event timer progress or added time', () => {
const first = getGroupTimer(makeState());
const second = getGroupTimer(
makeState({
timer: {
...makeState().timer,
addedTime: -8_000,
current: -50_000,
elapsed: 500_000,
},
}),
);
expect(second).toEqual(first);
});
it('keeps running while the loaded event is paused', () => {
const timer = getGroupTimer(makeState({ timer: { ...makeState().timer, playback: Playback.Pause } }));
expect(timer?.playback).toBe(Playback.Play);
});
it('handles a group running across midnight', () => {
const timer = getGroupTimer(
makeState({
clock: asTimeOfDay(1_000),
rundown: { ...makeState().rundown, actualGroupStart: 86_399_000 },
}),
);
expect(timer).toMatchObject({ current: 18_000, elapsed: 2_000 });
});
it('uses the event phase before the group starts and overtime afterwards', () => {
const pending = getGroupTimer(
makeState({
rundown: { ...makeState().rundown, actualGroupStart: null },
timer: { ...makeState().timer, phase: TimerPhase.Pending },
}),
);
const overtime = getGroupTimer(makeState({ clock: asTimeOfDay(30_001) }));
expect(pending).toMatchObject({ current: 20_000, elapsed: null, phase: TimerPhase.Pending });
expect(overtime).toMatchObject({ current: -5_001, elapsed: 25_001, phase: TimerPhase.Overtime });
});
});
describe('getElapsed()', () => {
it('returns active elapsed time from startedAt without add-time adjustments', () => {
const state = {
@@ -7,6 +7,7 @@ import {
findPreviousPlayableId,
getEventAtIndex,
getShouldClockUpdate,
getShouldGroupTimerUpdate,
getShouldOffsetUpdate,
getShouldTimerUpdate,
isNewSecond,
@@ -95,6 +96,34 @@ describe('getShouldTimerUpdate()', () => {
});
});
describe('getShouldGroupTimerUpdate()', () => {
const timer: TimerState = {
addedTime: 0,
current: 10_000,
duration: 10_000,
elapsed: 0,
expectedFinish: null,
phase: TimerPhase.Default,
playback: Playback.Play,
secondaryTimer: null,
startedAt: 0,
};
it('updates when a group timer appears or disappears', () => {
expect(getShouldGroupTimerUpdate(null, timer)).toBe(true);
expect(getShouldGroupTimerUpdate(timer, null)).toBe(true);
});
it('does not repeatedly publish an absent group timer', () => {
expect(getShouldGroupTimerUpdate(null, null)).toBe(false);
});
it('uses normal timer tick semantics while a group timer exists', () => {
expect(getShouldGroupTimerUpdate(timer, { ...timer, current: 9_500 })).toBe(false);
expect(getShouldGroupTimerUpdate(timer, { ...timer, current: 8_999 })).toBe(true);
});
});
describe('getShouldOffsetUpdate()', () => {
const baseOffset: Offset = {
absolute: 0,
@@ -25,7 +25,7 @@ import { logger } from '../../classes/Logger.js';
import { timerConfig } from '../../setup/config.js';
import { eventStore } from '../../stores/EventStore.js';
import * as runtimeState from '../../stores/runtimeState.js';
import type { RuntimeState } from '../../stores/runtimeState.js';
import type { RuntimeState, RuntimeStateSnapshot } from '../../stores/runtimeState.js';
import { EventTimer } from '../EventTimer.js';
import { restoreService } from '../restore-service/restore.service.js';
import type { RestorePoint } from '../restore-service/restore.type.js';
@@ -36,6 +36,7 @@ import {
findPreviousPlayableId,
getEventAtIndex,
getShouldClockUpdate,
getShouldGroupTimerUpdate,
getShouldOffsetUpdate,
getShouldTimerUpdate,
isNewSecond,
@@ -51,11 +52,11 @@ class RuntimeService {
private lastIntegrationTimerValue = -1;
/** last known state */
static previousState: RuntimeState;
static previousState: RuntimeStateSnapshot;
constructor(eventTimer: EventTimer) {
this.eventTimer = eventTimer;
RuntimeService.previousState = {} as RuntimeState;
RuntimeService.previousState = {} as RuntimeStateSnapshot;
}
@broadcastResult
@@ -710,6 +711,12 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
RuntimeService.previousState.timer = { ...state.timer };
}
const updateGroupTimer = getShouldGroupTimerUpdate(RuntimeService.previousState.groupTimer, state.groupTimer);
if (updateGroupTimer) {
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
@@ -54,6 +54,15 @@ export function getShouldTimerUpdate(previousValue: TimerState | undefined, curr
);
}
export function getShouldGroupTimerUpdate(
previousValue: TimerState | null | undefined,
currentValue: TimerState | null,
): boolean {
if (previousValue === undefined) return true;
if (previousValue === null || currentValue === null) return previousValue !== currentValue;
return getShouldTimerUpdate(previousValue, currentValue);
}
/**
* Checks whether we should update the offset values
* - `mode` triggers update
+32 -1
View File
@@ -1,4 +1,4 @@
import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types';
import { Day, MaybeNumber, Playback, TimeOfDay, TimerPhase, TimerState } from 'ontime-types';
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
import type { RuntimeState } from '../stores/runtimeState.js';
@@ -111,6 +111,37 @@ export function getElapsed(state: RuntimeState): MaybeNumber {
return Math.max(0, activeElapsed);
}
/**
* Derives a timer for the active group from wall-clock time.
* Event timer controls such as pause and add time intentionally do not affect it.
*/
export function getGroupTimer(state: RuntimeState): TimerState | null {
if (state.groupNow === null) {
return null;
}
const { actualGroupStart } = state.rundown;
const elapsed = actualGroupStart === null ? null : getTimeSinceStart(state.clock, actualGroupStart);
const current = elapsed === null ? state.groupNow.duration : state.groupNow.duration - elapsed;
let phase = state.timer.phase;
if (actualGroupStart !== null) {
phase = current < 0 ? TimerPhase.Overtime : TimerPhase.Default;
}
return {
addedTime: 0,
current,
duration: state.groupNow.duration,
elapsed,
expectedFinish: null,
phase,
playback: actualGroupStart === null ? state.timer.playback : Playback.Play,
secondaryTimer: null,
startedAt: actualGroupStart,
};
}
function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number {
if (clock < startedAt) {
return clock + dayInMs - startedAt;
+6 -1
View File
@@ -13,6 +13,7 @@ import {
Playback,
Rundown,
RundownState,
RuntimeStore,
TimeOfDay,
TimerPhase,
TimerState,
@@ -39,6 +40,7 @@ import {
getCurrent,
getElapsed,
getExpectedFinish,
getGroupTimer,
getRuntimeOffset,
getTimerPhase,
hasCrossedMidnight,
@@ -104,7 +106,9 @@ const runtimeState: RuntimeState = {
_startDayOffset: null,
};
export function getState(): Readonly<RuntimeState> {
export type RuntimeStateSnapshot = RuntimeState & Pick<RuntimeStore, 'groupTimer'>;
export function getState(): Readonly<RuntimeStateSnapshot> {
// create a shallow copy of the state
return {
...runtimeState,
@@ -115,6 +119,7 @@ export function getState(): Readonly<RuntimeState> {
offset: { ...runtimeState.offset },
rundown: { ...runtimeState.rundown },
timer: { ...runtimeState.timer },
groupTimer: getGroupTimer(runtimeState),
_timer: { ...runtimeState._timer },
_rundown: { ...runtimeState._rundown },
};