refactor(timer): elapsed is active time from start

This commit is contained in:
Carlos Valente
2026-06-28 19:12:22 +02:00
committed by Carlos Valente
parent 4f4626a163
commit 161ab3fe13
12 changed files with 196 additions and 6 deletions
@@ -5,6 +5,7 @@ import type { RuntimeState } from '../../stores/runtimeState.js';
import { import {
findDayOffset, findDayOffset,
getCurrent, getCurrent,
getElapsed,
getExpectedFinish, getExpectedFinish,
getRuntimeOffset, getRuntimeOffset,
getTimerPhase, getTimerPhase,
@@ -15,6 +16,56 @@ import {
const asTimeOfDay = (value: number): RuntimeState['clock'] => value as RuntimeState['clock']; 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()', () => { describe('getExpectedFinish()', () => {
it('is null if we havent started', () => { it('is null if we havent started', () => {
const state = { const state = {
@@ -47,6 +47,23 @@ describe('isRestorePoint()', () => {
expect(isRestorePoint(restorePoint)).toBe(true); 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', () => { describe('rejects a badly formatted file', () => {
it('with invalid playback value', () => { it('with invalid playback value', () => {
const restorePoint = { const restorePoint = {
@@ -46,6 +46,10 @@ export function isRestorePoint(restorePoint: unknown): restorePoint is RestorePo
return false; return false;
} }
if ('pausedDuration' in restorePoint && !is.number(restorePoint.pausedDuration)) {
return false;
}
if (!is.number(restorePoint.firstStart) && restorePoint.firstStart !== null) { if (!is.number(restorePoint.firstStart) && restorePoint.firstStart !== null) {
return false; return false;
} }
@@ -6,6 +6,7 @@ export type RestorePoint = {
startedAt: MaybeNumber; startedAt: MaybeNumber;
addedTime: number; addedTime: number;
pausedAt: MaybeNumber; pausedAt: MaybeNumber;
pausedDuration?: number;
firstStart: MaybeNumber; firstStart: MaybeNumber;
startEpoch: Maybe<Instant>; startEpoch: Maybe<Instant>;
currentDay: MaybeNumber; currentDay: MaybeNumber;
@@ -760,6 +760,7 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
startedAt: state.timer.startedAt, startedAt: state.timer.startedAt,
addedTime: state.timer.addedTime, addedTime: state.timer.addedTime,
pausedAt: state._timer.pausedAt, pausedAt: state._timer.pausedAt,
pausedDuration: state._timer.pausedDuration,
firstStart: state.rundown.actualStart, firstStart: state.rundown.actualStart,
startEpoch: state._startEpoch, startEpoch: state._startEpoch,
currentDay: state.rundown.currentDay, currentDay: state.rundown.currentDay,
@@ -33,7 +33,8 @@ export function getShouldClockUpdate(previousUpdate: number, now: number): boole
* Checks whether we should update the timer values * Checks whether we should update the timer values
* - `current` and `secondaryTimer` trigger on seconds roll over * - `current` and `secondaryTimer` trigger on seconds roll over
* - the rest trigger on any change * - 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 { export function getShouldTimerUpdate(previousValue: TimerState | undefined, currentValue: TimerState): boolean {
if (previousValue === undefined) return true; if (previousValue === undefined) return true;
@@ -48,7 +49,7 @@ export function getShouldTimerUpdate(previousValue: TimerState | undefined, curr
previousValue.phase !== currentValue.phase || previousValue.phase !== currentValue.phase ||
previousValue.playback !== currentValue.playback || previousValue.playback !== currentValue.playback ||
previousValue.startedAt !== currentValue.startedAt 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 // expectedFinish - this will be moved out by the current value going into over time, no need to check
); );
} }
+27
View File
@@ -92,6 +92,33 @@ export function getCurrent(state: RuntimeState): number {
return startedAt + duration + addedTime - clock - correctDay; 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 * Checks whether we have skipped out of the event
* @param {RuntimeState} state runtime state * @param {RuntimeState} state runtime state
@@ -40,6 +40,7 @@ const baseState: RuntimeState = {
_timer: { _timer: {
forceFinish: null, forceFinish: null,
pausedAt: null, pausedAt: null,
pausedDuration: 0,
secondaryTarget: null, secondaryTarget: null,
hasFinished: false, hasFinished: false,
}, },
@@ -61,6 +61,13 @@ vi.mock('../../classes/data-provider/DataProvider.js', () => {
return { return {
setCustomFields: vi.fn().mockImplementation((newData) => newData), setCustomFields: vi.fn().mockImplementation((newData) => newData),
setRundown: 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(); 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 () => { test('runtime offset', async () => {
+9 -2
View File
@@ -36,6 +36,7 @@ import { loadRoll, normaliseRollStart } from '../services/rollUtils.js';
import { import {
findDayOffset, findDayOffset,
getCurrent, getCurrent,
getElapsed,
getExpectedFinish, getExpectedFinish,
getRuntimeOffset, getRuntimeOffset,
getTimerPhase, getTimerPhase,
@@ -62,6 +63,7 @@ export type RuntimeState = {
_timer: { _timer: {
forceFinish: Maybe<TimeOfDay>; // whether we should declare an event as finished, will contain the finish time forceFinish: Maybe<TimeOfDay>; // whether we should declare an event as finished, will contain the finish time
pausedAt: Maybe<TimeOfDay>; pausedAt: Maybe<TimeOfDay>;
pausedDuration: number;
secondaryTarget: Maybe<TimeOfDay>; secondaryTarget: Maybe<TimeOfDay>;
hasFinished: boolean; hasFinished: boolean;
}; };
@@ -87,6 +89,7 @@ const runtimeState: RuntimeState = {
_timer: { _timer: {
forceFinish: null, forceFinish: null,
pausedAt: null, pausedAt: null,
pausedDuration: 0,
secondaryTarget: null, secondaryTarget: null,
hasFinished: false, hasFinished: false,
}, },
@@ -138,6 +141,7 @@ export function clearEventData() {
// when clearing, we maintain the total delay from the rundown // when clearing, we maintain the total delay from the rundown
runtimeState._timer.forceFinish = null; runtimeState._timer.forceFinish = null;
runtimeState._timer.pausedAt = null; runtimeState._timer.pausedAt = null;
runtimeState._timer.pausedDuration = 0;
runtimeState._timer.secondaryTarget = null; runtimeState._timer.secondaryTarget = null;
runtimeState._timer.hasFinished = false; runtimeState._timer.hasFinished = false;
} }
@@ -170,6 +174,7 @@ export function clearState() {
// when clearing, we maintain the total delay from the rundown // when clearing, we maintain the total delay from the rundown
runtimeState._timer.forceFinish = null; runtimeState._timer.forceFinish = null;
runtimeState._timer.pausedAt = null; runtimeState._timer.pausedAt = null;
runtimeState._timer.pausedDuration = 0;
runtimeState._timer.secondaryTarget = null; runtimeState._timer.secondaryTarget = null;
runtimeState._timer.hasFinished = false; runtimeState._timer.hasFinished = false;
@@ -426,6 +431,7 @@ export function start(state: RuntimeState = runtimeState): boolean {
if (state._timer.pausedAt) { if (state._timer.pausedAt) {
const timeToAdd = state.clock - state._timer.pausedAt; const timeToAdd = state.clock - state._timer.pausedAt;
state.timer.addedTime += timeToAdd; state.timer.addedTime += timeToAdd;
state._timer.pausedDuration += timeToAdd;
state._timer.pausedAt = null; state._timer.pausedAt = null;
} }
@@ -435,7 +441,7 @@ export function start(state: RuntimeState = runtimeState): boolean {
state.timer.playback = Playback.Play; state.timer.playback = Playback.Play;
state.timer.expectedFinish = getExpectedFinish(state); state.timer.expectedFinish = getExpectedFinish(state);
state.timer.elapsed = 0; state.timer.elapsed = getElapsed(state);
if (state.rundown.actualStart === null) { if (state.rundown.actualStart === null) {
state._startDayOffset = (findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset) as Day; 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 // we can update the state after handling the side effects
runtimeState.timer.addedTime += amount; runtimeState.timer.addedTime += amount;
runtimeState.timer.current += amount; runtimeState.timer.current += amount;
runtimeState.timer.elapsed = getElapsed(runtimeState);
// update runtime delays: over - under // update runtime delays: over - under
const { absolute, relative } = getRuntimeOffset(runtimeState); const { absolute, relative } = getRuntimeOffset(runtimeState);
@@ -575,7 +582,7 @@ export function update(): UpdateResult {
runtimeState.timer.current = getCurrent(runtimeState); runtimeState.timer.current = getCurrent(runtimeState);
runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState); runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState);
runtimeState.timer.phase = getTimerPhase(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 // update runtime, needs up-to-date timer state
const { absolute, relative } = getRuntimeOffset(runtimeState); const { absolute, relative } = getRuntimeOffset(runtimeState);
@@ -10,7 +10,7 @@ export const runtimeStorePlaceholder: Readonly<RuntimeStore> = {
addedTime: 0, addedTime: 0,
current: null, // changes on every update current: null, // changes on every update
duration: null, // only changes if event changes 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 expectedFinish: null, // change can only be initiated by user, can roll over midnight
phase: TimerPhase.None, // can change on update or user action phase: TimerPhase.None, // can change on update or user action
playback: Playback.Stop, // change initiated by user playback: Playback.Stop, // change initiated by user
@@ -21,7 +21,7 @@ export type TimerState = {
current: MaybeNumber; current: MaybeNumber;
/** Total duration of the running event */ /** Total duration of the running event */
duration: MaybeNumber; duration: MaybeNumber;
/** Time elapsed since the timer started */ /** Active time elapsed since the timer started */
elapsed: MaybeNumber; elapsed: MaybeNumber;
/** Timestamp of the expected finish time */ /** Timestamp of the expected finish time */
expectedFinish: MaybeNumber; expectedFinish: MaybeNumber;