mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-06 06:49:10 +00:00
feat: count to group completion
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user