Compare commits

..

1 Commits

Author SHA1 Message Date
alex-arc 6d7cd416dc fix: count to end ccrossing midnight 2026-07-25 17:47:13 +02:00
5 changed files with 145 additions and 138 deletions
@@ -1,7 +1,6 @@
import { EndAction, Instant, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
import { EndAction, 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,
@@ -54,12 +53,11 @@ 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: timeCore.toInstant((7 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()),
pausedAt: 7 * MILLIS_PER_MINUTE,
pausedDuration: 1 * MILLIS_PER_MINUTE,
},
} as RuntimeState;
@@ -227,7 +225,7 @@ describe('getExpectedFinish()', () => {
expect(calculatedFinish).toBe(10);
});
describe('on timers of type time-to-end', () => {
it('finish time is as schedule + added time', () => {
it('finish time is the fixed end, ignoring added time', () => {
const state = {
eventNow: {
timeEnd: 30,
@@ -247,6 +245,29 @@ describe('getExpectedFinish()', () => {
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(40);
});
it('finish time is the fixed end, including paused time', () => {
const state = {
eventNow: {
timeEnd: 30,
countToEnd: true,
},
clock: 25,
timer: {
addedTime: 0,
duration: dayInMs,
startedAt: 10,
},
_timer: {
pausedAt: 20, // paused 5 ago
hasFinished: false,
},
} as RuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(35);
});
it('handles events that finish the day after', () => {
const state = {
eventNow: {
@@ -406,6 +427,9 @@ describe('getCurrent()', () => {
it('current time is the time to end even if it hasnt started, this is weird, but by design', () => {
const state = {
eventNow: {
timeStart: 0,
duration: 100,
dayOffset: 0,
timeEnd: 100,
countToEnd: true,
},
@@ -431,6 +455,9 @@ describe('getCurrent()', () => {
it('current time is the time to end', () => {
const state = {
eventNow: {
timeStart: 0,
duration: 100,
dayOffset: 0,
timeEnd: 100,
countToEnd: true,
},
@@ -453,10 +480,13 @@ describe('getCurrent()', () => {
expect(current).toBe(70);
});
it('current time is the time to end + added time', () => {
it('current time is the time to end, ignoring added time', () => {
const state = {
eventNow: {
timeStart: 0,
duration: 100,
timeEnd: 100,
dayOffset: 0,
countToEnd: true,
},
clock: 30,
@@ -482,8 +512,10 @@ describe('getCurrent()', () => {
const state = {
eventNow: {
timeStart: 79200000, // 22:00:00
duration: 2 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE,
timeEnd: 600000, // 00:10:00
countToEnd: true,
dayOffset: 0,
},
clock: 79500000, // 22:05:00
timer: {
@@ -494,6 +526,7 @@ describe('getCurrent()', () => {
rundown: {
actualStart: 79200000,
plannedEnd: 600000,
currentDay: 0,
},
_timer: {
pausedAt: null,
@@ -505,6 +538,36 @@ describe('getCurrent()', () => {
expect(current).toBe(dayInMs - 79500000 + 600000);
});
it('handles overnight count-to-end after midnight', () => {
const state = {
eventNow: {
timeStart: 23 * MILLIS_PER_HOUR, // 23:00:00
duration: 2 * MILLIS_PER_HOUR, // 2h
timeEnd: 1 * MILLIS_PER_HOUR, // 01:00:00
countToEnd: true,
dayOffset: 0,
},
clock: 30 * MILLIS_PER_MINUTE, // 00:30:00 on day 1
timer: {
addedTime: 0,
duration: Infinity, // not relevant,
startedAt: 23 * MILLIS_PER_HOUR,
},
rundown: {
actualStart: 23 * MILLIS_PER_HOUR,
plannedEnd: 1 * MILLIS_PER_HOUR,
currentDay: 1,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as RuntimeState;
const current = getCurrent(state);
expect(current).toBe(30 * MILLIS_PER_MINUTE);
});
it('handles events that were started late', () => {
const state = {
clock: 82000000, // 22:46:40 <--- starting 16 min after the scheduled end
@@ -513,6 +576,7 @@ describe('getCurrent()', () => {
timeEnd: 81000000, // 22:30:00
duration: 3600000, // 01:00:00
countToEnd: true,
dayOffset: 0,
},
timer: {
addedTime: 0,
@@ -977,40 +1041,6 @@ describe('getRuntimeOffset()', () => {
expect(absolute).toBe(25);
});
it('paused time is delayed time when the pause spans midnight', () => {
const state = {
eventNow: {
id: '1',
timeStart: 23 * MILLIS_PER_HOUR, // 23:00
timeEnd: 1 * MILLIS_PER_HOUR, // 01:00
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: 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,
plannedStart: 23 * MILLIS_PER_HOUR,
currentDay: 0,
},
_startDayOffset: 0,
} as RuntimeState;
// 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', () => {
const state = {
clock: 78480789,
@@ -1109,7 +1139,7 @@ describe('getRuntimeOffset()', () => {
expect(absolute).toBe(0);
});
it('with time-to-end, offset is the overtime', () => {
it('with time-to-end, offset combines overtime and added time', () => {
const state = {
clock: 82000000, // 22:46:40
eventNow: {
@@ -1162,7 +1192,45 @@ describe('getRuntimeOffset()', () => {
} as RuntimeState;
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(400000 - 200000); // <--- offset is always the overtime + added time
// overtime (400000) plus the operator's added time (-200000)
expect(absolute).toBe(200000);
});
it('with time-to-end', () => {
const state = {
clock: 80000000, // 22:13:20 - before the scheduled end, not in overtime
eventNow: {
id: 'd6a2ce',
timeStart: 77400000, // 21:30:00
timeEnd: 81000000, // 22:30:00
duration: 3600000, // 01:00:00
timeStrategy: TimeStrategy.LockEnd,
countToEnd: true,
dayOffset: 0,
delay: 0,
},
rundown: {
plannedStart: 77400000, // 21:30:00
plannedEnd: 81000000, // 22:30:00
actualStart: 78000000, // 21:40:00
currentDay: 0,
},
offset: {
absolute: 0,
},
timer: {
addedTime: 300000, // operator added 5 minutes
current: 1000000, // still counting down, no overtime
duration: 3600000,
startedAt: 78000000,
},
_startDayOffset: 0,
_timer: { pausedAt: null },
} as RuntimeState;
// the end is anchored, so the added 5 minutes shows up as offset
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(300000);
});
it('handles time-to-end started after the end time', () => {
+17 -19
View File
@@ -1,7 +1,6 @@
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';
/**
@@ -63,52 +62,51 @@ export function getExpectedFinish(state: RuntimeState): MaybeNumber {
*/
export function getCurrent(state: RuntimeState): number {
const { timer, eventNow } = state;
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: {
if (state.eventNow === null || state.timer.duration === null) {
if (eventNow === null || timer.duration === null) {
throw new Error('timerUtils.getCurrent: invalid state received');
}
}
const { startedAt, duration, addedTime } = state.timer;
const { countToEnd, timeStart, timeEnd } = state.eventNow;
const { pausedAt } = state._timer;
const { clock } = state;
if (countToEnd) {
const isEventOverMidnight = timeStart > timeEnd;
const correctDay = isEventOverMidnight ? dayInMs : 0;
return correctDay - clock + timeEnd + addedTime;
if (eventNow.countToEnd) {
const clockDayCorrected = clock + (state.rundown.currentDay ?? 0) * dayInMs;
const eventDayOffset = eventNow.dayOffset * dayInMs;
return eventDayOffset + eventNow.timeStart + eventNow.duration + timer.addedTime - clockDayCorrected;
}
if (startedAt === null) {
return duration;
if (timer.startedAt === null) {
return timer.duration;
}
if (pausedAt != null) {
return startedAt + duration + addedTime - pausedAt;
return timer.startedAt + timer.duration + timer.addedTime - pausedAt;
}
const hasPassedMidnight = startedAt > clock;
const hasPassedMidnight = timer.startedAt > clock;
const correctDay = hasPassedMidnight ? dayInMs : 0;
return startedAt + duration + addedTime - clock - correctDay;
return timer.startedAt + timer.duration + timer.addedTime - clock - correctDay;
}
/**
* Calculates active time elapsed since the timer started.
*/
export function getElapsed(state: RuntimeState): MaybeNumber {
const { clock, _now } = state;
const { clock } = state;
const { startedAt } = state.timer;
const { pausedDuration, pausedAt } = state._timer;
const { pausedAt, pausedDuration } = state._timer;
if (startedAt === null) {
return null;
}
const currentPauseDuration = pausedAt !== null ? timeCore.timeSince(_now, pausedAt) : 0;
const elapsedSinceStart = getTimeSinceStart(clock, startedAt);
const activeElapsed = elapsedSinceStart - pausedDuration - currentPauseDuration;
const referenceClock = pausedAt ?? clock;
const elapsedSinceStart = getTimeSinceStart(referenceClock, startedAt);
const activeElapsed = elapsedSinceStart - pausedDuration;
return Math.max(0, activeElapsed);
}
@@ -1,11 +1,10 @@
import { Instant, OffsetMode, Playback, type TimeOfDay, TimerPhase } from 'ontime-types';
import { 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,
@@ -135,7 +135,7 @@ describe('mutation on runtimeState', () => {
playback: Playback.Pause,
addedTime: 0,
});
expect(newState._timer.pausedAt).toEqual(newState._now);
expect(newState._timer.pausedAt).toEqual(newState.clock);
success = pause();
expect(success).toBe(false);
@@ -248,59 +248,6 @@ describe('mutation on runtimeState', () => {
state = getState();
expect(state.timer.elapsed).toBe(3 * MILLIS_PER_MINUTE);
});
test('elapsed excludes a pause that spans midnight', async () => {
clearState();
// an event that runs over midnight (23:00 -> 01:00)
const event = {
...mockEvent,
id: 'elapsed-pause-midnight',
timeStart: 23 * MILLIS_PER_HOUR,
timeEnd: 1 * MILLIS_PER_HOUR,
duration: 2 * MILLIS_PER_HOUR,
};
const mockRundown = makeRundown({
entries: { [event.id]: event },
order: [event.id],
});
await initRundown(mockRundown, {});
vi.runAllTimers();
const { metadata, rundown } = rundownCache.get();
// start before midnight
vi.setSystemTime('jan 1 23:50');
load(event, rundown, metadata);
start();
// 8 minutes of active running before we pause
vi.setSystemTime('jan 1 23:58');
update();
expect(getState().timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
pause();
// elapsed is active time since start, so it must not advance while paused,
// not even when the pause itself crosses midnight
vi.setSystemTime('jan 2 00:01');
update();
expect(getState().timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
// resume 5 minutes after pausing, having crossed midnight (23:58 -> 00:03)
vi.setSystemTime('jan 2 00:03');
start();
let state = getState();
// the accumulated pause count is 5 minutes, regardless of the midnight wrap
expect(state._timer.pausedDuration).toBe(5 * MILLIS_PER_MINUTE);
// and elapsed still reflects only the 8 active minutes
expect(state.timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
// 2 more active minutes after resume -> 10 minutes elapsed
vi.setSystemTime('jan 2 00:05');
update();
state = getState();
expect(state.timer.elapsed).toBe(10 * MILLIS_PER_MINUTE);
});
});
test('runtime offset', async () => {
+16 -21
View File
@@ -63,9 +63,7 @@ export type RuntimeState = {
// private properties of the timer calculations
_timer: {
forceFinish: Maybe<TimeOfDay>; // whether we should declare an event as finished, will contain the finish time
pausedAt: Maybe<Instant>;
/** Accumulate pause duration but dose not include the current pause */
pausedAt: Maybe<TimeOfDay>;
pausedDuration: number;
secondaryTarget: Maybe<TimeOfDay>;
hasFinished: boolean;
@@ -78,12 +76,10 @@ export type RuntimeState = {
_end: ExpectedMetadata;
_startEpoch: Maybe<Instant>;
_startDayOffset: Maybe<Day>;
_now: Instant;
};
const runtimeState: RuntimeState = {
clock: timeCore.timeOfDayNow(),
_now: timeCore.now(),
groupNow: null,
eventNow: null,
eventNext: null,
@@ -108,12 +104,6 @@ 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<RuntimeState> {
// create a shallow copy of the state
return {
@@ -146,7 +136,7 @@ export function clearEventData() {
runtimeState.rundown.selectedEventIndex = null;
runtimeState.timer.playback = Playback.Stop;
setClock(runtimeState);
runtimeState.clock = timeCore.timeOfDayNow();
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
// when clearing, we maintain the total delay from the rundown
@@ -179,7 +169,7 @@ export function clearState() {
runtimeState._end = null;
runtimeState.timer.playback = Playback.Stop;
setClock(runtimeState);
runtimeState.clock = timeCore.timeOfDayNow();
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
// when clearing, we maintain the total delay from the rundown
@@ -432,12 +422,15 @@ export function start(state: RuntimeState = runtimeState): boolean {
return false;
}
setClock(state);
const epoch = timeCore.now();
const now = timeCore.toTimeOfDay(epoch);
state.clock = now;
state.timer.secondaryTimer = null;
// add paused time if it exists
if (state._timer.pausedAt) {
const timeToAdd = state._now - state._timer.pausedAt;
const timeToAdd = state.clock - state._timer.pausedAt;
state.timer.addedTime += timeToAdd;
state._timer.pausedDuration += timeToAdd;
state._timer.pausedAt = null;
@@ -454,7 +447,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 = state._now;
state._startEpoch = epoch;
state.rundown.actualStart = state.clock;
}
@@ -488,8 +481,8 @@ export function pause(state: RuntimeState = runtimeState): boolean {
}
state.timer.playback = Playback.Pause;
setClock(state);
state._timer.pausedAt = state._now;
state.clock = timeCore.timeOfDayNow();
state._timer.pausedAt = state.clock;
return true;
}
@@ -554,7 +547,9 @@ export type UpdateResult = {
export function update(): UpdateResult {
// 0. there are some things we always do
const previousClock = runtimeState.clock;
setClock(runtimeState); // we update the clock on every update call
const epoch = timeCore.now();
const now = timeCore.toTimeOfDay(epoch);
runtimeState.clock = now; // we update the clock on every update call
// 1. is playback idle?
if (!isPlaybackActive(runtimeState.timer.playback)) {
@@ -563,13 +558,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, runtimeState._now);
const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, epoch);
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, runtimeState.clock);
const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, now);
return updateIfWaitingToRoll(clockHasCrossedMidnight);
}