diff --git a/apps/server/src/services/__tests__/timerUtils.test.ts b/apps/server/src/services/__tests__/timerUtils.test.ts index 37b168c02..288bbc382 100644 --- a/apps/server/src/services/__tests__/timerUtils.test.ts +++ b/apps/server/src/services/__tests__/timerUtils.test.ts @@ -5,6 +5,7 @@ import type { RuntimeState } from '../../stores/runtimeState.js'; import { findDayOffset, getCurrent, + getElapsed, getExpectedFinish, getRuntimeOffset, getTimerPhase, @@ -15,6 +16,56 @@ import { const asTimeOfDay = (value: number): RuntimeState['clock'] => value as RuntimeState['clock']; +describe('getElapsed()', () => { + it('returns active elapsed time from startedAt without add-time adjustments', () => { + const state = { + clock: 5 * MILLIS_PER_MINUTE, + timer: { + addedTime: -10 * MILLIS_PER_MINUTE, + current: 15 * MILLIS_PER_MINUTE, + duration: 20 * MILLIS_PER_MINUTE, + startedAt: 2 * MILLIS_PER_MINUTE, + }, + _timer: { + pausedAt: null, + pausedDuration: 0, + }, + } as RuntimeState; + + expect(getElapsed(state)).toBe(3 * MILLIS_PER_MINUTE); + }); + + it('subtracts accumulated pause time', () => { + const state = { + clock: 10 * MILLIS_PER_MINUTE, + timer: { + startedAt: 2 * MILLIS_PER_MINUTE, + }, + _timer: { + pausedAt: null, + pausedDuration: 5 * MILLIS_PER_MINUTE, + }, + } as RuntimeState; + + expect(getElapsed(state)).toBe(3 * MILLIS_PER_MINUTE); + }); + + it('uses the current pause start while paused', () => { + const state = { + clock: 10 * MILLIS_PER_MINUTE, + timer: { + startedAt: 2 * MILLIS_PER_MINUTE, + }, + _timer: { + pausedAt: 7 * MILLIS_PER_MINUTE, + pausedDuration: 1 * MILLIS_PER_MINUTE, + }, + } as RuntimeState; + + expect(getElapsed(state)).toBe(4 * MILLIS_PER_MINUTE); + }); +}); + describe('getExpectedFinish()', () => { it('is null if we havent started', () => { const state = { diff --git a/apps/server/src/services/restore-service/__tests__/restore.parser.test.ts b/apps/server/src/services/restore-service/__tests__/restore.parser.test.ts index 8c335923a..6be1473c1 100644 --- a/apps/server/src/services/restore-service/__tests__/restore.parser.test.ts +++ b/apps/server/src/services/restore-service/__tests__/restore.parser.test.ts @@ -47,6 +47,23 @@ describe('isRestorePoint()', () => { expect(isRestorePoint(restorePoint)).toBe(true); }); + it('accepts optional paused duration', () => { + const restorePoint: RestorePoint = { + playback: Playback.Roll, + selectedEventId: '123', + startedAt: 1, + addedTime: 2, + pausedAt: null, + pausedDuration: 3000, + firstStart: 1, + startEpoch: asInstant(1), + currentDay: 0, + }; + + expect(isRestorePoint(restorePoint)).toBe(true); + expect(isRestorePoint({ ...restorePoint, pausedDuration: '3000' })).toBe(false); + }); + describe('rejects a badly formatted file', () => { it('with invalid playback value', () => { const restorePoint = { diff --git a/apps/server/src/services/restore-service/restore.parser.ts b/apps/server/src/services/restore-service/restore.parser.ts index a7cc2d309..8016de760 100644 --- a/apps/server/src/services/restore-service/restore.parser.ts +++ b/apps/server/src/services/restore-service/restore.parser.ts @@ -46,6 +46,10 @@ export function isRestorePoint(restorePoint: unknown): restorePoint is RestorePo return false; } + if ('pausedDuration' in restorePoint && !is.number(restorePoint.pausedDuration)) { + return false; + } + if (!is.number(restorePoint.firstStart) && restorePoint.firstStart !== null) { return false; } diff --git a/apps/server/src/services/restore-service/restore.type.ts b/apps/server/src/services/restore-service/restore.type.ts index c8a4aa6ff..0af426bd6 100644 --- a/apps/server/src/services/restore-service/restore.type.ts +++ b/apps/server/src/services/restore-service/restore.type.ts @@ -6,6 +6,7 @@ export type RestorePoint = { startedAt: MaybeNumber; addedTime: number; pausedAt: MaybeNumber; + pausedDuration?: number; firstStart: MaybeNumber; startEpoch: Maybe; currentDay: MaybeNumber; diff --git a/apps/server/src/services/runtime-service/runtime.service.ts b/apps/server/src/services/runtime-service/runtime.service.ts index d64564ea2..4c44ae858 100644 --- a/apps/server/src/services/runtime-service/runtime.service.ts +++ b/apps/server/src/services/runtime-service/runtime.service.ts @@ -760,6 +760,7 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert startedAt: state.timer.startedAt, addedTime: state.timer.addedTime, pausedAt: state._timer.pausedAt, + pausedDuration: state._timer.pausedDuration, firstStart: state.rundown.actualStart, startEpoch: state._startEpoch, currentDay: state.rundown.currentDay, diff --git a/apps/server/src/services/runtime-service/runtime.utils.ts b/apps/server/src/services/runtime-service/runtime.utils.ts index 9961a78fb..52c900872 100644 --- a/apps/server/src/services/runtime-service/runtime.utils.ts +++ b/apps/server/src/services/runtime-service/runtime.utils.ts @@ -33,7 +33,8 @@ export function getShouldClockUpdate(previousUpdate: number, now: number): boole * Checks whether we should update the timer values * - `current` and `secondaryTimer` trigger on seconds roll over * - the rest trigger on any change - * - `elapsed` and `expectedFinish` is not checked + * - `elapsed` changes alongside `current` + * - `expectedFinish` is not checked */ export function getShouldTimerUpdate(previousValue: TimerState | undefined, currentValue: TimerState): boolean { if (previousValue === undefined) return true; @@ -48,7 +49,7 @@ export function getShouldTimerUpdate(previousValue: TimerState | undefined, curr previousValue.phase !== currentValue.phase || previousValue.playback !== currentValue.playback || previousValue.startedAt !== currentValue.startedAt - // elapsed - this would be the direct invert of current value so no need to check + // elapsed - this is derived from current / duration / addedTime, so the fields above cover it // expectedFinish - this will be moved out by the current value going into over time, no need to check ); } diff --git a/apps/server/src/services/timerUtils.ts b/apps/server/src/services/timerUtils.ts index e3764cc7b..79589b4a3 100644 --- a/apps/server/src/services/timerUtils.ts +++ b/apps/server/src/services/timerUtils.ts @@ -92,6 +92,33 @@ export function getCurrent(state: RuntimeState): number { return startedAt + duration + addedTime - clock - correctDay; } +/** + * Calculates active time elapsed since the timer started. + */ +export function getElapsed(state: RuntimeState): MaybeNumber { + const { clock } = state; + const { startedAt } = state.timer; + const { pausedAt, pausedDuration } = state._timer; + + if (startedAt === null) { + return null; + } + + const referenceClock = pausedAt ?? clock; + const elapsedSinceStart = getTimeSinceStart(referenceClock, startedAt); + const activeElapsed = elapsedSinceStart - pausedDuration; + + return Math.max(0, activeElapsed); +} + +function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number { + if (clock < startedAt) { + return clock + dayInMs - startedAt; + } + + return clock - startedAt; +} + /** * Checks whether we have skipped out of the event * @param {RuntimeState} state runtime state diff --git a/apps/server/src/stores/__mocks__/runtimeState.mocks.ts b/apps/server/src/stores/__mocks__/runtimeState.mocks.ts index 4d57b11ce..eb9905f2b 100644 --- a/apps/server/src/stores/__mocks__/runtimeState.mocks.ts +++ b/apps/server/src/stores/__mocks__/runtimeState.mocks.ts @@ -40,6 +40,7 @@ const baseState: RuntimeState = { _timer: { forceFinish: null, pausedAt: null, + pausedDuration: 0, secondaryTarget: null, hasFinished: false, }, diff --git a/apps/server/src/stores/__tests__/runtimeState.test.ts b/apps/server/src/stores/__tests__/runtimeState.test.ts index 8b802e160..b5b532789 100644 --- a/apps/server/src/stores/__tests__/runtimeState.test.ts +++ b/apps/server/src/stores/__tests__/runtimeState.test.ts @@ -61,6 +61,13 @@ vi.mock('../../classes/data-provider/DataProvider.js', () => { return { setCustomFields: vi.fn().mockImplementation((newData) => newData), setRundown: vi.fn().mockImplementation((newData) => newData), + getAutomation: vi.fn().mockReturnValue({ + enabledAutomations: false, + enabledOscIn: false, + oscPortIn: 0, + triggers: [], + automations: {}, + }), }; }), }; @@ -168,6 +175,79 @@ describe('mutation on runtimeState', () => { }); expect(newState.rundown.actualStart).toBeNull(); }); + + test('elapsed remains active time when adding and removing time', async () => { + clearState(); + const event = { + ...mockEvent, + id: 'elapsed-add-time', + timeStart: 0, + timeEnd: 10 * MILLIS_PER_MINUTE, + duration: 10 * MILLIS_PER_MINUTE, + }; + const mockRundown = makeRundown({ + entries: { [event.id]: event }, + order: [event.id], + }); + + await initRundown(mockRundown, {}); + vi.runAllTimers(); + + const { metadata, rundown } = rundownCache.get(); + vi.setSystemTime('jan 1 00:00'); + load(event, rundown, metadata); + start(); + + vi.setSystemTime('jan 1 00:02'); + update(); + + addTime(5 * MILLIS_PER_MINUTE); + let state = getState(); + expect(state.timer.elapsed).toBe(2 * MILLIS_PER_MINUTE); + expect(state.timer.duration! - state.timer.current!).toBe(-3 * MILLIS_PER_MINUTE); + + addTime(-8 * MILLIS_PER_MINUTE); + state = getState(); + expect(state.timer.elapsed).toBe(2 * MILLIS_PER_MINUTE); + expect(state.timer.duration! - state.timer.current!).toBe(5 * MILLIS_PER_MINUTE); + }); + + test('elapsed excludes time spent paused after resume', async () => { + clearState(); + const event = { + ...mockEvent, + id: 'elapsed-pause', + timeStart: 0, + timeEnd: 10 * MILLIS_PER_MINUTE, + duration: 10 * MILLIS_PER_MINUTE, + }; + const mockRundown = makeRundown({ + entries: { [event.id]: event }, + order: [event.id], + }); + + await initRundown(mockRundown, {}); + vi.runAllTimers(); + + const { metadata, rundown } = rundownCache.get(); + vi.setSystemTime('jan 1 00:00'); + load(event, rundown, metadata); + start(); + + vi.setSystemTime('jan 1 00:02'); + update(); + pause(); + + vi.setSystemTime('jan 1 00:07'); + start(); + let state = getState(); + expect(state.timer.elapsed).toBe(2 * MILLIS_PER_MINUTE); + + vi.setSystemTime('jan 1 00:08'); + update(); + state = getState(); + expect(state.timer.elapsed).toBe(3 * MILLIS_PER_MINUTE); + }); }); test('runtime offset', async () => { diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index c65799c6e..d98591285 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -36,6 +36,7 @@ import { loadRoll, normaliseRollStart } from '../services/rollUtils.js'; import { findDayOffset, getCurrent, + getElapsed, getExpectedFinish, getRuntimeOffset, getTimerPhase, @@ -62,6 +63,7 @@ export type RuntimeState = { _timer: { forceFinish: Maybe; // whether we should declare an event as finished, will contain the finish time pausedAt: Maybe; + pausedDuration: number; secondaryTarget: Maybe; hasFinished: boolean; }; @@ -87,6 +89,7 @@ const runtimeState: RuntimeState = { _timer: { forceFinish: null, pausedAt: null, + pausedDuration: 0, secondaryTarget: null, hasFinished: false, }, @@ -138,6 +141,7 @@ export function clearEventData() { // when clearing, we maintain the total delay from the rundown runtimeState._timer.forceFinish = null; runtimeState._timer.pausedAt = null; + runtimeState._timer.pausedDuration = 0; runtimeState._timer.secondaryTarget = null; runtimeState._timer.hasFinished = false; } @@ -170,6 +174,7 @@ export function clearState() { // when clearing, we maintain the total delay from the rundown runtimeState._timer.forceFinish = null; runtimeState._timer.pausedAt = null; + runtimeState._timer.pausedDuration = 0; runtimeState._timer.secondaryTarget = null; runtimeState._timer.hasFinished = false; @@ -426,6 +431,7 @@ export function start(state: RuntimeState = runtimeState): boolean { if (state._timer.pausedAt) { const timeToAdd = state.clock - state._timer.pausedAt; state.timer.addedTime += timeToAdd; + state._timer.pausedDuration += timeToAdd; state._timer.pausedAt = null; } @@ -435,7 +441,7 @@ export function start(state: RuntimeState = runtimeState): boolean { state.timer.playback = Playback.Play; state.timer.expectedFinish = getExpectedFinish(state); - state.timer.elapsed = 0; + state.timer.elapsed = getElapsed(state); if (state.rundown.actualStart === null) { state._startDayOffset = (findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset) as Day; @@ -520,6 +526,7 @@ export function addTime(amount: number) { // we can update the state after handling the side effects runtimeState.timer.addedTime += amount; runtimeState.timer.current += amount; + runtimeState.timer.elapsed = getElapsed(runtimeState); // update runtime delays: over - under const { absolute, relative } = getRuntimeOffset(runtimeState); @@ -575,7 +582,7 @@ export function update(): UpdateResult { runtimeState.timer.current = getCurrent(runtimeState); runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState); runtimeState.timer.phase = getTimerPhase(runtimeState); - runtimeState.timer.elapsed = runtimeState.timer.duration - runtimeState.timer.current; + runtimeState.timer.elapsed = getElapsed(runtimeState); // update runtime, needs up-to-date timer state const { absolute, relative } = getRuntimeOffset(runtimeState); diff --git a/packages/types/src/definitions/runtime/RuntimeStore.ts b/packages/types/src/definitions/runtime/RuntimeStore.ts index 00ebaaec8..a5e899abd 100644 --- a/packages/types/src/definitions/runtime/RuntimeStore.ts +++ b/packages/types/src/definitions/runtime/RuntimeStore.ts @@ -10,7 +10,7 @@ export const runtimeStorePlaceholder: Readonly = { addedTime: 0, current: null, // changes on every update duration: null, // only changes if event changes - elapsed: null, // changes on every update + elapsed: null, // active time from start, changes on every update expectedFinish: null, // change can only be initiated by user, can roll over midnight phase: TimerPhase.None, // can change on update or user action playback: Playback.Stop, // change initiated by user diff --git a/packages/types/src/definitions/runtime/TimerState.type.ts b/packages/types/src/definitions/runtime/TimerState.type.ts index 7b889eb84..626144876 100644 --- a/packages/types/src/definitions/runtime/TimerState.type.ts +++ b/packages/types/src/definitions/runtime/TimerState.type.ts @@ -21,7 +21,7 @@ export type TimerState = { current: MaybeNumber; /** Total duration of the running event */ duration: MaybeNumber; - /** Time elapsed since the timer started */ + /** Active time elapsed since the timer started */ elapsed: MaybeNumber; /** Timestamp of the expected finish time */ expectedFinish: MaybeNumber;