From a2d6847ea63e4874ca80d270f51726a4d94c2339 Mon Sep 17 00:00:00 2001 From: alex-arc Date: Sun, 26 Jul 2026 13:00:26 +0200 Subject: [PATCH] fix: pause timer over midnight --- .../src/services/__tests__/timerUtils.test.ts | 18 ++++++--- apps/server/src/services/timerUtils.ts | 12 +++--- .../stores/__mocks__/runtimeState.mocks.ts | 3 +- .../src/stores/__tests__/runtimeState.test.ts | 2 +- apps/server/src/stores/runtimeState.ts | 37 +++++++++++-------- 5 files changed, 43 insertions(+), 29 deletions(-) diff --git a/apps/server/src/services/__tests__/timerUtils.test.ts b/apps/server/src/services/__tests__/timerUtils.test.ts index 2465066a2..41b993bec 100644 --- a/apps/server/src/services/__tests__/timerUtils.test.ts +++ b/apps/server/src/services/__tests__/timerUtils.test.ts @@ -1,6 +1,7 @@ -import { EndAction, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types'; +import { EndAction, Instant, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types'; import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisToString } from 'ontime-utils'; +import * as timeCore from '../../lib/time-core/timeCore.js'; import type { RuntimeState } from '../../stores/runtimeState.js'; import { findDayOffset, @@ -53,11 +54,12 @@ describe('getElapsed()', () => { it('uses the current pause start while paused', () => { const state = { clock: 10 * MILLIS_PER_MINUTE, + _now: timeCore.toInstant((10 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()), timer: { startedAt: 2 * MILLIS_PER_MINUTE, }, _timer: { - pausedAt: 7 * MILLIS_PER_MINUTE, + pausedAt: timeCore.toInstant((7 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()), pausedDuration: 1 * MILLIS_PER_MINUTE, }, } as RuntimeState; @@ -984,13 +986,18 @@ describe('getRuntimeOffset()', () => { dayOffset: 0, }, clock: 3 * MILLIS_PER_MINUTE, // 00:03 (after midnight) + _now: timeCore.toInstant((3 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()), timer: { startedAt: 23 * MILLIS_PER_HOUR, // started on time at 23:00 current: 25, // still counting down addedTime: 0, }, _timer: { - pausedAt: 23 * MILLIS_PER_HOUR + 58 * MILLIS_PER_MINUTE, // 23:58, before midnight + pausedAt: timeCore.toInstant( + (23 * MILLIS_PER_HOUR + 58 * MILLIS_PER_MINUTE) as TimeOfDay, + (timeCore.now() - dayInMs) as Instant, + ), // 23:58, before midnight + pausedDuration: 0, }, rundown: { actualStart: 23 * MILLIS_PER_HOUR, @@ -1000,9 +1007,8 @@ describe('getRuntimeOffset()', () => { _startDayOffset: 0, } as RuntimeState; - // paused from 23:58 to 00:03 -> 5 minutes, regardless of the midnight wrap - const { absolute } = getRuntimeOffset(state); - expect(absolute).toBe(5 * MILLIS_PER_MINUTE); + // paused from 23:58 to 00:03 -> so elapsed should still be 58 minutes + expect(getElapsed(state)).toBe(58 * MILLIS_PER_MINUTE); }); it('offset doesnt exist if we havent started', () => { diff --git a/apps/server/src/services/timerUtils.ts b/apps/server/src/services/timerUtils.ts index 8c28a5e8d..aed095ecc 100644 --- a/apps/server/src/services/timerUtils.ts +++ b/apps/server/src/services/timerUtils.ts @@ -1,6 +1,7 @@ import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types'; import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils'; +import * as timeCore from '../lib/time-core/timeCore.js'; import type { RuntimeState } from '../stores/runtimeState.js'; /** @@ -96,17 +97,18 @@ export function getCurrent(state: RuntimeState): number { * Calculates active time elapsed since the timer started. */ export function getElapsed(state: RuntimeState): MaybeNumber { - const { clock } = state; + const { clock, _now } = state; const { startedAt } = state.timer; - const { pausedAt, pausedDuration } = state._timer; + const { pausedDuration, pausedAt } = state._timer; if (startedAt === null) { return null; } - const referenceClock = pausedAt ?? clock; - const elapsedSinceStart = getTimeSinceStart(referenceClock, startedAt); - const activeElapsed = elapsedSinceStart - pausedDuration; + const currentPauseDuration = pausedAt !== null ? timeCore.timeSince(_now, pausedAt) : 0; + + const elapsedSinceStart = getTimeSinceStart(clock, startedAt); + const activeElapsed = elapsedSinceStart - pausedDuration - currentPauseDuration; return Math.max(0, activeElapsed); } diff --git a/apps/server/src/stores/__mocks__/runtimeState.mocks.ts b/apps/server/src/stores/__mocks__/runtimeState.mocks.ts index eb9905f2b..2d97a21b6 100644 --- a/apps/server/src/stores/__mocks__/runtimeState.mocks.ts +++ b/apps/server/src/stores/__mocks__/runtimeState.mocks.ts @@ -1,10 +1,11 @@ -import { OffsetMode, Playback, type TimeOfDay, TimerPhase } from 'ontime-types'; +import { Instant, OffsetMode, Playback, type TimeOfDay, TimerPhase } from 'ontime-types'; import { deepmerge } from 'ontime-utils'; import type { RuntimeState } from '../runtimeState.js'; const baseState: RuntimeState = { clock: 0 as TimeOfDay, + _now: 0 as Instant, eventNow: null, eventNext: null, eventFlag: null, diff --git a/apps/server/src/stores/__tests__/runtimeState.test.ts b/apps/server/src/stores/__tests__/runtimeState.test.ts index fbc16e0ba..1d253e4ad 100644 --- a/apps/server/src/stores/__tests__/runtimeState.test.ts +++ b/apps/server/src/stores/__tests__/runtimeState.test.ts @@ -135,7 +135,7 @@ describe('mutation on runtimeState', () => { playback: Playback.Pause, addedTime: 0, }); - expect(newState._timer.pausedAt).toEqual(newState.clock); + expect(newState._timer.pausedAt).toEqual(newState._now); success = pause(); expect(success).toBe(false); diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index f306b154e..e6a08c831 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -63,7 +63,9 @@ export type RuntimeState = { // private properties of the timer calculations _timer: { forceFinish: Maybe; // whether we should declare an event as finished, will contain the finish time - pausedAt: Maybe; + pausedAt: Maybe; + + /** Accumulate pause duration but dose not include the current pause */ pausedDuration: number; secondaryTarget: Maybe; hasFinished: boolean; @@ -76,10 +78,12 @@ export type RuntimeState = { _end: ExpectedMetadata; _startEpoch: Maybe; _startDayOffset: Maybe; + _now: Instant; }; const runtimeState: RuntimeState = { clock: timeCore.timeOfDayNow(), + _now: timeCore.now(), groupNow: null, eventNow: null, eventNext: null, @@ -104,6 +108,12 @@ const runtimeState: RuntimeState = { _startDayOffset: null, }; +/** set the current clock to ensure parity between _now and clock */ +function setClock(state: RuntimeState) { + state._now = timeCore.now(); + state.clock = timeCore.toTimeOfDay(state._now); +} + export function getState(): Readonly { // create a shallow copy of the state return { @@ -136,7 +146,7 @@ export function clearEventData() { runtimeState.rundown.selectedEventIndex = null; runtimeState.timer.playback = Playback.Stop; - runtimeState.clock = timeCore.timeOfDayNow(); + setClock(runtimeState); runtimeState.timer = { ...runtimeStorePlaceholder.timer }; // when clearing, we maintain the total delay from the rundown @@ -169,7 +179,7 @@ export function clearState() { runtimeState._end = null; runtimeState.timer.playback = Playback.Stop; - runtimeState.clock = timeCore.timeOfDayNow(); + setClock(runtimeState); runtimeState.timer = { ...runtimeStorePlaceholder.timer }; // when clearing, we maintain the total delay from the rundown @@ -422,15 +432,12 @@ export function start(state: RuntimeState = runtimeState): boolean { return false; } - const epoch = timeCore.now(); - const now = timeCore.toTimeOfDay(epoch); - - state.clock = now; + setClock(state); state.timer.secondaryTimer = null; // add paused time if it exists if (state._timer.pausedAt) { - const timeToAdd = state.clock - state._timer.pausedAt; + const timeToAdd = state._now - state._timer.pausedAt; state.timer.addedTime += timeToAdd; state._timer.pausedDuration += timeToAdd; state._timer.pausedAt = null; @@ -447,7 +454,7 @@ export function start(state: RuntimeState = runtimeState): boolean { if (state.rundown.actualStart === null) { state._startDayOffset = (findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset) as Day; state.rundown.currentDay = state._startDayOffset; - state._startEpoch = epoch; + state._startEpoch = state._now; state.rundown.actualStart = state.clock; } @@ -481,8 +488,8 @@ export function pause(state: RuntimeState = runtimeState): boolean { } state.timer.playback = Playback.Pause; - state.clock = timeCore.timeOfDayNow(); - state._timer.pausedAt = state.clock; + setClock(state); + state._timer.pausedAt = state._now; return true; } @@ -547,9 +554,7 @@ export type UpdateResult = { export function update(): UpdateResult { // 0. there are some things we always do const previousClock = runtimeState.clock; - const epoch = timeCore.now(); - const now = timeCore.toTimeOfDay(epoch); - runtimeState.clock = now; // we update the clock on every update call + setClock(runtimeState); // we update the clock on every update call // 1. is playback idle? if (!isPlaybackActive(runtimeState.timer.playback)) { @@ -558,13 +563,13 @@ export function update(): UpdateResult { // calculate currentDay from epoch (days elapsed since playback was started) if (runtimeState._startEpoch !== null && runtimeState._startDayOffset !== null) { - const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, epoch); + const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, runtimeState._now); runtimeState.rundown.currentDay = runtimeState._startDayOffset + daysSinceStart; } // 2. are we waiting to roll? if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) { - const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, now); + const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, runtimeState.clock); return updateIfWaitingToRoll(clockHasCrossedMidnight); }