Feat: Time until group and flag (#1708)

* refactor: split event data and state data

* refactor: calculate the start time insted of until

* cleanup

* send next flag expected start from server

* refactor: render

* fix test

* use same icon

* refactor: group duration

* Optimize (#1711)

* refactor: consolidate block, flag and end loading and expected times

* work on test

* cal end value

* lint

* fix test

* remove todo
This commit is contained in:
Alex Christoffer Rasmussen
2025-08-07 07:55:28 +12:00
committed by GitHub
parent e37d2ce50f
commit 35347e8d63
19 changed files with 567 additions and 539 deletions
@@ -723,11 +723,11 @@ export function processRundown(
// if the event is a block, we process the nested entries
// the code here is a copy of the processing of top level events
if (isOntimeBlock(processedEntry)) {
let totalBlockDuration = 0;
let blockStartTime = null;
let blockEndTime = null;
let isFirstLinked = false;
const blockEvents: EntryId[] = [];
processedEntry.duration = 0;
// check if the block contains nested entries
for (let j = 0; j < processedEntry.entries.length; j++) {
@@ -739,10 +739,7 @@ export function processRundown(
}
blockEvents.push(nestedEntry.id);
const { processedData: processedNestedData, processedEntry: processedNestedEntry } = process(
nestedEntry,
processedEntry.id,
);
const { processedEntry: processedNestedEntry } = process(nestedEntry, processedEntry.id);
// we dont extract metadata of skipped events,
// if this is not a playable event there is nothing else to do
@@ -757,12 +754,14 @@ export function processRundown(
}
// lastEntry is the event with the latest end time
blockEndTime = processedNestedData.lastEnd;
totalBlockDuration += processedNestedEntry.duration;
blockEndTime = processedNestedEntry.timeEnd;
if (j > 0) {
processedEntry.duration += processedNestedEntry.gap;
}
processedEntry.duration = processedEntry.duration + processedNestedEntry.duration;
}
// update block metadata
processedEntry.duration = totalBlockDuration;
processedEntry.timeStart = blockStartTime;
processedEntry.timeEnd = blockEndTime;
processedEntry.isFirstLinked = isFirstLinked;
@@ -715,14 +715,14 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
getForceUpdate(RuntimeService.previousRuntimeUpdate, state.clock)) &&
!deepEqual(RuntimeService.previousState?.runtime, state.runtime);
/**
* the currentBlock object has the potential to tick on expected end
* TODO: the value shows up one tick to late
*/
// TODO: the value shows up one tick to late
const shouldBlockUpdate =
!deepEqual(RuntimeService?.previousState.blockNow, state.blockNow) ||
RuntimeService?.previousState.blockNext !== state.blockNext;
// TODO: the value shows up one tick to late
const shouldNextFlagUpdate = !deepEqual(RuntimeService?.previousState?.nextFlag, state.nextFlag);
/**
* Many other values are calculated based on the clock
* so if any of them are updated we also need to send the clock
@@ -755,9 +755,9 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
RuntimeService.previousState.blockNext = state.blockNext;
}
if (RuntimeService.previousState?.nextFlag !== state.nextFlag) {
if (shouldNextFlagUpdate) {
batch.add('nextFlag', state.nextFlag);
RuntimeService.previousState.nextFlag = state.nextFlag;
RuntimeService.previousState.nextFlag = structuredClone(state.nextFlag);
}
if (hasImmediateChanges) {
+2 -68
View File
@@ -1,67 +1,12 @@
import { isOntimeEvent, MaybeNumber, OffsetMode, OntimeBlock, Rundown, TimerPhase } from 'ontime-types';
import { calculateTimeUntilStart, dayInMs, getLastEventNormal, isPlaybackActive } from 'ontime-utils';
import { MaybeNumber, TimerPhase } from 'ontime-types';
import { dayInMs, isPlaybackActive } from 'ontime-utils';
import type { RuntimeState } from '../stores/runtimeState.js';
import { shouldCrashDev } from '../utils/development.js';
/**
* handle events that span over midnight
*/
export const normaliseEndTime = (start: number, end: number) => (end < start ? end + dayInMs : end);
/**
* Calculates the expected time of the group to end.
* Should only be called if a block is running
* TODO: take a look at how it handles relative offset mode
*/
export function getExpectedBlockFinish(state: RuntimeState, rundown: Rundown): MaybeNumber {
const { blockNow, eventNow, timer, clock } = state;
if (blockNow === null) return null;
// if the group doesn't have a start time there is no end time either
if (blockNow.startedAt === null) return null;
if (eventNow === null) return null;
if (timer.current === null) return null;
const { entries } = rundown;
const orderInBlock = (entries[blockNow.id] as OntimeBlock).entries;
const indexInBlock = orderInBlock.findIndex((id) => eventNow.id === id);
shouldCrashDev(indexInBlock < 0, 'Running event is not in current block');
if (indexInBlock === orderInBlock.length - 1) return timer.expectedFinish;
let totalGap = 0;
let isLinkedToLoaded = true;
for (let i = indexInBlock + 1; i < orderInBlock.length; i++) {
const entry = entries[orderInBlock[i]];
if (isOntimeEvent(entry)) {
totalGap += entry.gap;
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart;
}
}
const { lastEvent } = getLastEventNormal(rundown.entries, orderInBlock);
if (!lastEvent) return null;
const { offsetMode, offsetAbs, offsetRel, plannedStart, actualStart } = state.runtime;
const timeUntilLastEvent = calculateTimeUntilStart({
timeStart: lastEvent.timeStart,
dayOffset: lastEvent.dayOffset,
delay: lastEvent.delay,
currentDay: eventNow.dayOffset,
totalGap,
isLinkedToLoaded,
clock,
offsetMode,
offset: offsetMode === OffsetMode.Absolute ? offsetAbs : offsetRel,
plannedStart,
actualStart,
});
return clock + timeUntilLastEvent + lastEvent.duration;
}
/**
* Calculates expected finish time of a running timer
* @param {RuntimeState} state runtime state
@@ -221,17 +166,6 @@ export function getRuntimeOffset(state: RuntimeState): { offsetAbs: number; offs
return { offsetAbs: offset, offsetRel };
}
/**
* Calculates the expected end of the rundown
*/
export function getExpectedEnd(state: RuntimeState): MaybeNumber {
// there is no expected end if we havent started
if (state.runtime.actualStart === null || state.runtime.plannedEnd === null) {
return null;
}
return state.runtime.plannedEnd - state.runtime.offsetAbs + state._rundown.totalDelay;
}
/**
* Checks running timer to see which phase it currently is in
* @param state
@@ -40,6 +40,9 @@ const baseState: RuntimeState = {
_rundown: {
totalDelay: 0,
},
_block: null,
_end: null,
_flag: null,
};
export function makeRuntimeStateData(patch?: Partial<RuntimeState>): RuntimeState {
@@ -9,13 +9,14 @@ import {
clearState,
getState,
load,
loadBlock,
loadBlockFlagAndEnd,
pause,
roll,
start,
stop,
} from '../runtimeState.js';
import { rundownCache } from '../../api-data/rundown/rundown.dao.js';
import { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
const mockEvent = {
type: 'event',
@@ -388,7 +389,9 @@ describe('loadBlock', () => {
eventNow: rundown.entries[11],
} as unknown as RuntimeState;
loadBlock(rundown, state);
const metadata = { playableEventOrder: ['0', '11', '3'], flags: ['1'] } as RundownMetadata;
loadBlockFlagAndEnd(rundown, metadata, 2, state);
expect(state).toMatchObject({
blockNow: { id: rundown.entries[1].id, startedAt: null },
@@ -413,7 +416,9 @@ describe('loadBlock', () => {
eventNow: rundown.entries[22],
} as RuntimeState;
loadBlock(rundown, state);
const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata;
loadBlockFlagAndEnd(rundown, metadata, 1, state);
expect(state).toMatchObject({
blockNow: { id: rundown.entries[2].id, startedAt: null },
@@ -441,7 +446,9 @@ describe('loadBlock', () => {
eventNow: rundown.entries[0],
} as RuntimeState;
loadBlock(rundown, state);
const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata;
loadBlockFlagAndEnd(rundown, metadata, 1, state);
expect(state).toMatchObject({
blockNow: null,
@@ -464,7 +471,10 @@ describe('loadBlock', () => {
eventNow: rundown.entries[2],
} as RuntimeState;
loadBlock(rundown, state);
const metadata = { playableEventOrder: ['1', '2'], flags: ['1'] } as RundownMetadata;
loadBlockFlagAndEnd(rundown, metadata, 0, state);
expect(state).toMatchObject({
blockNow: { id: rundown.entries[0].id, startedAt: 123 },
eventNow: rundown.entries[2],
@@ -485,7 +495,9 @@ describe('loadBlock', () => {
eventNow: rundown.entries[0],
} as RuntimeState;
loadBlock(rundown, state);
const metadata = { playableEventOrder: ['0', '1'], flags: ['1'] } as RundownMetadata;
loadBlockFlagAndEnd(rundown, metadata, 0, state);
expect(state).toMatchObject({
blockNow: null,
+168 -80
View File
@@ -1,10 +1,12 @@
import {
CurrentBlockState,
isOntimeBlock,
EntryMetaData,
isOntimeEvent,
MaybeNumber,
MaybeString,
OffsetMode,
OntimeBlock,
OntimeEvent,
PlayableEvent,
Playback,
Rundown,
@@ -12,31 +14,31 @@ import {
runtimeStorePlaceholder,
TimerPhase,
TimerState,
UpcomingEntry,
} from 'ontime-types';
import { calculateDuration, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
import {
calculateDuration,
checkIsNow,
dayInMs,
getExpectedStart,
getLastEventNormal,
isPlaybackActive,
} from 'ontime-utils';
import { timeNow } from '../utils/time.js';
import type { RestorePoint } from '../services/RestoreService.js';
import {
getCurrent,
getExpectedBlockFinish,
getExpectedEnd,
getExpectedFinish,
getRuntimeOffset,
getTimerPhase,
} from '../services/timerUtils.js';
import { getCurrent, getExpectedFinish, getRuntimeOffset, getTimerPhase } from '../services/timerUtils.js';
import { loadRoll, normaliseRollStart } from '../services/rollUtils.js';
import { timerConfig } from '../setup/config.js';
import { RundownMetadata } from '../api-data/rundown/rundown.types.js';
import { getPlayableIndexFromTimedIndex } from '../api-data/rundown/rundown.utils.js';
import { getCurrentRundown } from '../api-data/rundown/rundown.dao.js';
type ExpectedMetadata = { event: OntimeEvent; accumulatedGap: number; isLinkedToLoaded: boolean } | null;
export type RuntimeState = {
clock: number; // realtime clock
blockNow: CurrentBlockState | null;
blockNext: MaybeString;
nextFlag: UpcomingEntry | null;
nextFlag: EntryMetaData | null;
eventNow: PlayableEvent | null;
eventNext: PlayableEvent | null;
runtime: Runtime;
@@ -50,6 +52,9 @@ export type RuntimeState = {
_rundown: {
totalDelay: number; // this value comes from rundown service
};
_block: ExpectedMetadata;
_flag: ExpectedMetadata;
_end: ExpectedMetadata;
};
const runtimeState: RuntimeState = {
@@ -69,6 +74,9 @@ const runtimeState: RuntimeState = {
_rundown: {
totalDelay: 0,
},
_block: null,
_flag: null,
_end: null,
};
export function getState(): Readonly<RuntimeState> {
@@ -96,6 +104,7 @@ export function clearEventData() {
runtimeState.runtime.expectedEnd = null;
runtimeState.runtime.selectedEventIndex = null;
//TODO: is there any ExpectedMetadata stuff we need to clear here
if (runtimeState.blockNow) runtimeState.blockNow.expectedEnd = null;
runtimeState.timer.playback = Playback.Stop;
@@ -115,12 +124,16 @@ export function clearState() {
runtimeState.blockNow = null;
runtimeState.blockNext = null;
runtimeState._block = null;
runtimeState.nextFlag = null;
runtimeState._flag = null;
runtimeState.runtime.offsetAbs = 0;
runtimeState.runtime.offsetRel = 0;
runtimeState.runtime.actualStart = null;
runtimeState.runtime.expectedEnd = null;
runtimeState._end = null;
runtimeState.runtime.selectedEventIndex = null;
runtimeState.timer.playback = Playback.Stop;
@@ -171,7 +184,7 @@ export function updateRundownData(rundownData: RundownData) {
runtimeState.runtime.plannedStart = rundownData.firstStart;
runtimeState.runtime.plannedEnd =
rundownData.firstStart === null ? null : rundownData.firstStart + rundownData.totalDuration;
runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
getExpectedTimes();
}
/**
@@ -199,8 +212,7 @@ export function load(
// load events in memory along with their data
loadNow(rundown, metadata, eventIndex);
loadNext(rundown, metadata, eventIndex);
loadBlock(rundown);
loadNextFlag(eventIndex, rundown, metadata);
loadBlockFlagAndEnd(rundown, metadata, eventIndex);
// update state
runtimeState.timer.playback = Playback.Armed;
@@ -217,7 +229,7 @@ export function load(
const { offsetAbs, offsetRel } = getRuntimeOffset(runtimeState);
runtimeState.runtime.offsetAbs = offsetAbs;
runtimeState.runtime.offsetRel = offsetRel;
runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
getExpectedTimes();
}
if (typeof initialData.blockStartAt === 'number' && runtimeState.blockNow) {
runtimeState.blockNow.startedAt = initialData.blockStartAt;
@@ -341,8 +353,7 @@ export function updateAll(rundown: Rundown, metadata: RundownMetadata) {
loadNow(rundown, metadata, eventNowIndex >= 0 ? eventNowIndex : undefined);
loadNext(rundown, metadata, eventNowIndex >= 0 ? eventNowIndex : undefined);
updateLoaded(runtimeState.eventNow ?? undefined);
loadBlock(rundown);
loadNextFlag(eventNowIndex, rundown, metadata);
loadBlockFlagAndEnd(rundown, metadata, eventNowIndex);
}
export function start(state: RuntimeState = runtimeState): boolean {
@@ -397,7 +408,7 @@ export function start(state: RuntimeState = runtimeState): boolean {
}
}
state.runtime.expectedEnd = state.runtime.plannedEnd - state.runtime.offsetAbs;
getExpectedTimes();
return true;
}
@@ -460,7 +471,7 @@ export function addTime(amount: number) {
const { offsetAbs, offsetRel } = getRuntimeOffset(runtimeState);
runtimeState.runtime.offsetAbs = offsetAbs;
runtimeState.runtime.offsetRel = offsetRel;
runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
getExpectedTimes();
return true;
}
@@ -507,7 +518,7 @@ export function update(): UpdateResult {
const { offsetAbs, offsetRel } = getRuntimeOffset(runtimeState);
runtimeState.runtime.offsetAbs = offsetAbs;
runtimeState.runtime.offsetRel = offsetRel;
runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
// runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
const finishedNow =
Boolean(runtimeState._timer.forceFinish) ||
@@ -520,10 +531,7 @@ export function update(): UpdateResult {
runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState);
}
if (runtimeState.blockNow) {
const expectedBlockFinish = getExpectedBlockFinish(runtimeState, getCurrentRundown());
runtimeState.blockNow.expectedEnd = expectedBlockFinish;
}
getExpectedTimes();
return { hasTimerFinished: finishedNow, hasSecondaryTimerFinished: false };
@@ -632,7 +640,7 @@ export function roll(
// load events in memory along with their data
loadNow(rundown, metadata, index);
loadNext(rundown, metadata, index);
loadBlock(rundown);
loadBlockFlagAndEnd(rundown, metadata, index);
// update roll state
runtimeState.timer.playback = Playback.Roll;
@@ -690,72 +698,152 @@ export function roll(
}
/**
* handle block loading, not for use outside of runtimeState
* calculates and sets values directly in state
* - runtime.expectedEnd
* - blockNow.expectedEnd
* - nextFlag.expectedStart
*/
export function loadBlock(rundown: Rundown, state = runtimeState) {
// we need a loaded event to have a block
if (state.eventNow === null) {
state.blockNow = null;
state.blockNext = null;
return;
function getExpectedTimes(state = runtimeState) {
const { offsetMode, offsetAbs, offsetRel, plannedStart, actualStart } = state.runtime;
const { eventNow } = state;
if (!eventNow) return;
state.runtime.expectedEnd = null;
if (state.blockNow) {
state.blockNow.expectedEnd = null;
const { _block } = state;
if (state.blockNow.startedAt !== null && _block !== null) {
const { event, accumulatedGap, isLinkedToLoaded } = _block;
const expectedStart = getExpectedStart(event, {
currentDay: eventNow.dayOffset,
totalGap: accumulatedGap,
isLinkedToLoaded,
offsetMode,
offset: offsetMode === OffsetMode.Absolute ? offsetAbs : offsetRel,
plannedStart,
actualStart,
});
state.blockNow.expectedEnd = expectedStart + event.duration;
}
}
if (state.nextFlag) {
state.nextFlag.expectedStart = null;
const { _flag } = state;
if (_flag) {
const { event, accumulatedGap, isLinkedToLoaded } = _flag;
const expectedStart = getExpectedStart(event, {
currentDay: eventNow.dayOffset,
totalGap: accumulatedGap,
isLinkedToLoaded,
offsetMode,
offset: offsetMode === OffsetMode.Absolute ? offsetAbs : offsetRel,
plannedStart,
actualStart,
});
state.nextFlag.expectedStart = expectedStart;
}
}
if (state._end) {
const { event, accumulatedGap, isLinkedToLoaded } = state._end;
const expectedStart = getExpectedStart(event, {
currentDay: eventNow.dayOffset,
totalGap: accumulatedGap,
isLinkedToLoaded,
offsetMode,
offset: offsetMode === OffsetMode.Absolute ? offsetAbs : offsetRel,
plannedStart,
actualStart,
});
state.runtime.expectedEnd = expectedStart + event.duration;
} else {
state.runtime.expectedEnd = null;
}
}
export function loadBlockFlagAndEnd(
rundown: Rundown,
metadata: RundownMetadata,
currentIndex: MaybeNumber,
state = runtimeState,
) {
if (currentIndex === null) return resetMetaData();
if (state.eventNow === null) return resetMetaData();
const currentBlockId = state.eventNow.parent;
const flagsPresent = metadata.flags.length !== 0;
// look for potential next block
let foundEventNow = false;
for (const id of rundown.order) {
if (foundEventNow && isOntimeBlock(rundown.entries[id])) {
state.blockNext = id; // the id is set here, the start time is set in other placed that handel starting events
break;
}
if (id === state.eventNow.id) {
foundEventNow = true;
continue;
const { playableEventOrder } = metadata;
const { entries } = rundown;
const orderInBlock = currentBlockId ? (entries[currentBlockId] as OntimeBlock).entries : null;
const lastEventInGroup = orderInBlock ? getLastEventNormal(rundown.entries, orderInBlock).lastEvent : null;
// if we don't have a any flags in the rundown then no need to look for it
let foundFlag = !flagsPresent;
let foundNextGroup = false;
// if we don't have a last event for the group there is no need to find its end time
let foundGroupEnd = lastEventInGroup === null;
let accumulatedGap = 0;
let isLinkedToLoaded = true;
for (let idx = currentIndex; idx < playableEventOrder.length; idx++) {
const entry = entries[playableEventOrder[idx]];
if (isOntimeEvent(entry)) {
if (idx !== currentIndex) {
// we only accumulate data after the loaded event
accumulatedGap += entry.gap;
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart;
// and the loaded event is not allowed to be the next flag
if (!foundFlag && metadata.flags.includes(entry.id)) {
foundFlag = true;
state.nextFlag = { id: entry.id, actualStart: null, expectedStart: null, expectedEnd: null };
state._flag = { event: entry, isLinkedToLoaded, accumulatedGap };
}
}
if (!foundGroupEnd && entry.id === lastEventInGroup?.id) {
foundGroupEnd = true;
state._block = { event: lastEventInGroup, isLinkedToLoaded, accumulatedGap };
}
if (!foundNextGroup && entry.parent !== currentBlockId) {
foundNextGroup = true;
state.blockNext = entry.parent;
}
}
}
// not inside a block
const lastID = playableEventOrder.at(-1);
const lastEvent = lastID ? (entries[lastID] as OntimeEvent) : null;
if (lastEvent) {
state._end = { event: lastEvent, isLinkedToLoaded, accumulatedGap };
}
if (!foundFlag) state.nextFlag = null;
if (currentBlockId === null) {
state.blockNow = null;
return;
}
//we went into a new block - and it is different from the one we might have come from
if ((state.blockNow != null && state.blockNow.id != currentBlockId) || state.blockNow == null) {
state.blockNow = { id: currentBlockId, startedAt: null, expectedEnd: null }; // the id is set here, the start time is set when starting events
} else if ((state.blockNow != null && state.blockNow.id != currentBlockId) || state.blockNow == null) {
// we went into a new block - and it is different from the one we might have come from
// the id is set here, the start time is set when starting events
state.blockNow = { id: currentBlockId, startedAt: null, expectedEnd: null };
}
}
/**
* find and load the next flag from the currently loaded event
*/
export function loadNextFlag(currentIndex: number, rundown: Rundown, metadata: RundownMetadata) {
runtimeState.nextFlag = null;
if (metadata.flags.length === 0) {
return;
}
// we are in the last event
if (currentIndex + 1 >= metadata.timedEventOrder.length) {
return;
}
for (let i = currentIndex + 1; i < metadata.timedEventOrder.length; i++) {
const entryId = metadata.timedEventOrder[i];
if (metadata.flags.includes(entryId)) {
const event = rundown.entries[entryId];
if (!event || !isOntimeEvent(event)) {
continue;
}
runtimeState.nextFlag = { id: event.id, start: event.timeStart };
return;
}
}
return null;
}
const resetMetaData = (state = runtimeState) => {
state.blockNow = null;
state.blockNext = null;
state._block = null;
state.nextFlag = null;
state._flag = null;
state._end = null;
};
export function setOffsetMode(mode: OffsetMode) {
runtimeState.runtime.offsetMode = mode;