fix: backdate rundown start when rolling

This commit is contained in:
Carlos Valente
2026-03-08 00:41:07 +01:00
committed by Carlos Valente
parent 5ff7861968
commit 6f1f10ccff
10 changed files with 235 additions and 31 deletions
@@ -16,6 +16,7 @@ import {
Trigger,
URLPreset,
ViewSettings,
Day,
} from 'ontime-types';
import {
customFieldLabelToKey,
@@ -393,7 +394,7 @@ export function migrateRundown(
// !==== RUNTIME METADATA ====! //
revision: -1,
delay: 0,
dayOffset: 0,
dayOffset: 0 as Day,
gap: 0,
});
} else if (entry.type === 'block') {
@@ -16,6 +16,7 @@ import {
isOntimeMilestone,
OntimeMilestone,
OntimeGroup,
Day,
} from 'ontime-types';
import {
isObjectEmpty,
@@ -281,7 +282,7 @@ function processEntry<T extends OntimeEntry>(
sanitiseCustomFields(customFields, currentEntry);
processedData.totalDays += calculateDayOffset(currentEntry, processedData.previousEvent);
currentEntry.dayOffset = processedData.totalDays;
currentEntry.dayOffset = processedData.totalDays as Day;
currentEntry.delay = 0; // this means we dont calculate delays or gaps for skipped events
currentEntry.gap = 0; // this means we dont calculate delays or gaps for skipped events
currentEntry.parent = childOfGroup;
@@ -1,5 +1,5 @@
import { dayInMs, millisToString } from 'ontime-utils';
import { Duration, Instant } from 'ontime-types';
import { dayInMs, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, millisToString } from 'ontime-utils';
import { Duration, Instant, TimeOfDay } from 'ontime-types';
import { timeNow } from '../../../utils/time.js';
import * as timeCore from '../timeCore.js';
@@ -183,3 +183,81 @@ describe('addDuration() moves a point in time by a duration', () => {
expect(timeCore.addDuration(instant, [])).toBe(1000);
});
});
describe('elapsedTime() calculates duration between two times of day', () => {
it('calculates elapsed time on the same day', () => {
const start = (10 * MILLIS_PER_HOUR) as TimeOfDay; // 10:00
const clock = (10 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE) as TimeOfDay; // 10:30
expect(timeCore.elapsedTime(clock, start)).toBe(30 * MILLIS_PER_MINUTE);
});
it('calculates elapsed time when crossing midnight (overnight)', () => {
const start = (23 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE) as TimeOfDay; // 23:50
const clock = (21 * MILLIS_PER_MINUTE) as TimeOfDay; // 00:21
// From 23:50 to 00:21 = 31 minutes
expect(timeCore.elapsedTime(clock, start)).toBe(31 * MILLIS_PER_MINUTE);
});
it('returns 0 when start and clock are the same', () => {
const time = (15 * MILLIS_PER_HOUR) as TimeOfDay; // 15:00
expect(timeCore.elapsedTime(time, time)).toBe(0);
});
it('calculates correctly for just after midnight', () => {
const start = (23 * MILLIS_PER_HOUR + 59 * MILLIS_PER_MINUTE) as TimeOfDay; // 23:59
const clock = (1 * MILLIS_PER_MINUTE) as TimeOfDay; // 00:01
// From 23:59 to 00:01 = 2 minutes
expect(timeCore.elapsedTime(clock, start)).toBe(2 * MILLIS_PER_MINUTE);
});
});
describe('daysSinceStart() calculates full days elapsed since a start epoch', () => {
it('returns 0 when current epoch equals start epoch', () => {
vi.setSystemTime('2025-01-15T10:00:00Z');
const epoch = timeCore.now();
expect(timeCore.daysSinceStart(epoch, epoch)).toBe(0);
});
it('returns 0 when less than one day has elapsed', () => {
vi.setSystemTime('2025-01-15T10:00:00Z');
const startEpoch = timeCore.now();
vi.setSystemTime('2025-01-15T18:00:00Z'); // 8 hours later
const currentEpoch = timeCore.now();
expect(timeCore.daysSinceStart(startEpoch, currentEpoch)).toBe(0);
});
it('returns 1 when crossing midnight once', () => {
// Copenhagen is UTC+1 in winter, so 22:50 UTC = 23:50 local
vi.setSystemTime('2025-01-15T22:50:00Z'); // 23:50 local
const startEpoch = timeCore.now();
vi.setSystemTime('2025-01-15T23:21:00Z'); // 00:21 local next day
const currentEpoch = timeCore.now();
expect(timeCore.daysSinceStart(startEpoch, currentEpoch)).toBe(1);
});
it('returns 2 when crossing midnight twice', () => {
vi.setSystemTime('2025-01-15T10:00:00Z');
const startEpoch = timeCore.now();
vi.setSystemTime('2025-01-17T15:00:00Z'); // 2 days + 5 hours later
const currentEpoch = timeCore.now();
expect(timeCore.daysSinceStart(startEpoch, currentEpoch)).toBe(2);
});
it('handles overnight start correctly', () => {
// Copenhagen is UTC+1 in winter
// Start at 23:50 local (22:50 UTC), check at 00:10 local next day (23:10 UTC)
vi.setSystemTime('2025-01-15T22:50:00Z'); // 23:50 local
const startEpoch = timeCore.now();
vi.setSystemTime('2025-01-15T23:10:00Z'); // 00:10 local next day
const currentEpoch = timeCore.now();
expect(timeCore.daysSinceStart(startEpoch, currentEpoch)).toBe(1);
});
});
+19 -1
View File
@@ -1,4 +1,4 @@
import { Instant, TimeOfDay, Duration } from 'ontime-types';
import { Day, Duration, Instant, TimeOfDay } from 'ontime-types';
import { dayInMs, MILLIS_PER_MINUTE } from 'ontime-utils';
/** Returns the current instant */
@@ -58,3 +58,21 @@ export function addDuration(instant: Instant, duration: Duration | Duration[]):
return (instant + totalDuration) as Instant;
}
/**
* Calculates elapsed time on the clock from a starting time to the current time
* Handles overnight crossing (when current < start, assumes we've crossed midnight)
*/
export function elapsedTime(current: TimeOfDay, start: TimeOfDay): Duration {
return (current < start ? current + dayInMs - start : current - start) as Duration;
}
/**
* Calculates the number of full days elapsed since a start epoch
* Uses the start time-of-day to determine day boundaries
*/
export function daysSinceStart(startEpoch: Instant, currentEpoch: Instant): Day {
const startClock = toTimeOfDay(startEpoch);
const elapsedMs = currentEpoch - startEpoch;
return Math.floor((elapsedMs + startClock) / dayInMs) as Day;
}
+7 -7
View File
@@ -1,4 +1,4 @@
import { DatabaseModel, EndAction, OntimeView, SupportedEntry, TimeStrategy, TimerType } from 'ontime-types';
import { DatabaseModel, Day, EndAction, OntimeView, SupportedEntry, TimeStrategy, TimerType } from 'ontime-types';
export const demoDb: DatabaseModel = {
rundowns: {
@@ -66,7 +66,7 @@ export const demoDb: DatabaseModel = {
note: 'Music plays, holding slide on screens',
colour: '#77C785',
delay: 0,
dayOffset: 0,
dayOffset: 0 as Day,
gap: 0,
cue: '1',
parent: '7eaf99',
@@ -109,7 +109,7 @@ export const demoDb: DatabaseModel = {
note: 'Emma Thompson',
colour: '#FFCC78',
delay: 0,
dayOffset: 0,
dayOffset: 0 as Day,
gap: 0,
cue: '1.1',
parent: '7eaf99',
@@ -141,7 +141,7 @@ export const demoDb: DatabaseModel = {
note: 'Liam Carter, Sophia Patel + PowerPoint',
colour: '#77C785',
delay: 0,
dayOffset: 0,
dayOffset: 0 as Day,
gap: 0,
cue: '1.2',
parent: '7eaf99',
@@ -199,7 +199,7 @@ export const demoDb: DatabaseModel = {
note: 'Buffet in lobby',
colour: '#779BE7',
delay: 0,
dayOffset: 0,
dayOffset: 0 as Day,
gap: 0,
cue: '2.1',
parent: 'f60403',
@@ -257,7 +257,7 @@ export const demoDb: DatabaseModel = {
note: 'Ethan Brooks + PowerPoint + Video playback',
colour: '#77C785',
delay: 0,
dayOffset: 0,
dayOffset: 0 as Day,
gap: 0,
cue: '3.1',
parent: '6b0edb',
@@ -289,7 +289,7 @@ export const demoDb: DatabaseModel = {
note: 'Lucas Bennett',
colour: '#FFCC78',
delay: 0,
dayOffset: 0,
dayOffset: 0 as Day,
gap: 0,
cue: '3.2',
parent: '6b0edb',
+5 -5
View File
@@ -1,4 +1,4 @@
import { MaybeNumber, TimerPhase } from 'ontime-types';
import { Day, MaybeNumber, TimerPhase } from 'ontime-types';
import { checkIsNow, dayInMs, isPlaybackActive, MILLIS_PER_HOUR } from 'ontime-utils';
import type { RuntimeState } from '../stores/runtimeState.js';
@@ -188,9 +188,9 @@ export function getTimerPhase(state: RuntimeState): TimerPhase {
* Finds the day offset relative to an event start
* used byt the runtimeState on first start to get correct offsets
*/
export function findDayOffset(plannedStart: number, clock: number): number {
export function findDayOffset(plannedStart: number, clock: number): Day {
const distance = clock - plannedStart;
if (distance >= 12 * MILLIS_PER_HOUR) return -1;
if (distance < -12 * MILLIS_PER_HOUR) return 1;
return 0;
if (distance >= 12 * MILLIS_PER_HOUR) return -1 as Day;
if (distance < -12 * MILLIS_PER_HOUR) return 1 as Day;
return 0 as Day;
}
@@ -1,4 +1,4 @@
import { Instant, PlayableEvent, Playback, TimerPhase } from 'ontime-types';
import { Instant, PlayableEvent, Playback, SupportedEntry, TimerPhase } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
import { makeOntimeGroup, makeOntimeEvent, makeRundown } from '../../api-data/rundown/__mocks__/rundown.mocks.js';
@@ -575,6 +575,95 @@ describe('roll mode', () => {
expect(stateAfterMidnight.timer.secondaryTimer).toBe(50 * MILLIS_PER_MINUTE);
});
test('rolling into overnight event after midnight has correct offset and expected times', async () => {
// Simulates a rundown with:
// - A group starting at 13:00, containing an overnight event
// - Overnight event: 23:50 to 01:50 (2 hours)
// Roll into the event at 00:21 (31 minutes into the event)
const groupId = 'group-1';
const eventId = 'event-1';
const eventStart = 23 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE; // 23:50
const eventEnd = 1 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE; // 01:50
const eventDuration = 2 * MILLIS_PER_HOUR; // 2 hours
const groupStart = 13 * MILLIS_PER_HOUR; // 13:00
const groupDuration = 12 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE; // 12h 50m (ends at 01:50)
const mockRundown = makeRundown({
entries: {
[groupId]: {
id: groupId,
type: SupportedEntry.Group,
title: 'Test Group',
timeStart: groupStart,
timeEnd: eventEnd,
duration: groupDuration,
entries: [eventId],
colour: '',
note: '',
custom: {},
revision: 0,
isFirstLinked: false,
targetDuration: null,
},
[eventId]: {
...mockEvent,
id: eventId,
timeStart: eventStart,
timeEnd: eventEnd,
duration: eventDuration,
dayOffset: 0,
parent: groupId,
},
},
order: [groupId],
});
await initRundown(mockRundown, {});
vi.runAllTimers();
const { rundown, metadata } = rundownCache.get();
// Roll into the event AFTER midnight at 00:21 (31 minutes into the 2-hour event)
const rollTime = 21 * MILLIS_PER_MINUTE; // 00:21
vi.setSystemTime('jan 2 00:21');
const result = roll(rundown, metadata);
expect(result.eventId).toBe(eventId);
expect(result.didStart).toBe(true);
// Call update to recalculate all state (like runtime service does)
update();
const state = getState();
// currentDay should be 1 (we're on the next day after midnight)
expect(state.rundown.currentDay).toBe(1);
// offset.absolute should be 0 (event started on time, backdated to planned start)
expect(state.offset.absolute).toBe(0);
// Timer should show correct remaining time
// Event started at 23:50, duration is 2 hours, clock is 00:21
// Time elapsed = 31 minutes, time remaining = 2h - 31m = 1h 29m = 89 minutes
const expectedRemaining = eventDuration - (rollTime + (24 * MILLIS_PER_HOUR - eventStart));
expect(state.timer.current).toBe(expectedRemaining);
// expectedFinish should be 01:50 (event end time)
expect(state.timer.expectedFinish).toBe(eventEnd);
// expectedRundownEnd should be 01:50 (same as event end, only one event)
expect(state.offset.expectedRundownEnd).toBe(eventEnd);
// expectedGroupEnd should be 01:50 (group ends when the event ends)
expect(state.offset.expectedGroupEnd).toBe(eventEnd);
// Verify timer started at correct backdated time
expect(state.timer.startedAt).toBe(eventStart);
// Verify actualStart is backdated to planned start
expect(state.rundown.actualStart).toBe(eventStart);
});
test('rundown loops back after finishing and resets currentDay when first event starts again', async () => {
// Rundown: 09:00-23:00, then overnight event 23:00-01:00
const mockRundown = makeRundown({
+26 -10
View File
@@ -1,4 +1,6 @@
import {
Day,
Duration,
isOntimeEvent,
Instant,
MaybeNumber,
@@ -69,7 +71,7 @@ export type RuntimeState = {
_flag: ExpectedMetadata;
_end: ExpectedMetadata;
_startEpoch: Maybe<Instant>;
_startDayOffset: MaybeNumber;
_startDayOffset: Maybe<Day>;
};
const runtimeState: RuntimeState = {
@@ -435,7 +437,7 @@ export function start(state: RuntimeState = runtimeState): boolean {
state.timer.elapsed = 0;
if (state.rundown.actualStart === null) {
state._startDayOffset = findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset;
state._startDayOffset = (findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset) as Day;
state.rundown.currentDay = state._startDayOffset;
state._startEpoch = epoch;
state.rundown.actualStart = state.clock;
@@ -688,12 +690,21 @@ export function roll(
runtimeState.rundown.actualGroupStart = plannedStart;
}
/**
* we need to backdate the actual start and start metadata
* to prevent adding unintended offset
*/
if (runtimeState.rundown.actualStart === null) {
runtimeState.rundown.actualStart = plannedStart;
runtimeState._startDayOffset =
findDayOffset(runtimeState.eventNow.timeStart, runtimeState.clock) + runtimeState.eventNow.dayOffset;
runtimeState.rundown.currentDay = runtimeState._startDayOffset;
runtimeState._startEpoch = epoch;
// use plannedStart (not clock) because actualStart is backdated to plannedStart
runtimeState._startDayOffset = (findDayOffset(runtimeState.eventNow.timeStart, plannedStart) +
runtimeState.eventNow.dayOffset) as Day;
// backdate _startEpoch to when the event conceptually started
const timeElapsed = timeCore.elapsedTime(runtimeState.clock, plannedStart as TimeOfDay);
runtimeState._startEpoch = timeCore.addDuration(epoch, -timeElapsed as Duration);
// calculate currentDay from the backdated epoch
runtimeState.rundown.currentDay =
runtimeState._startDayOffset + timeCore.daysSinceStart(runtimeState._startEpoch, epoch);
}
} else {
runtimeState._timer.secondaryTarget = normaliseRollStart(
@@ -789,10 +800,15 @@ export function roll(
}
// update metadata
runtimeState._startDayOffset =
findDayOffset(runtimeState.eventNow.timeStart, runtimeState.clock) + runtimeState.eventNow.dayOffset;
runtimeState.rundown.currentDay = runtimeState._startDayOffset;
runtimeState._startEpoch = epoch;
// use plannedStart (not clock) because actualStart is backdated to plannedStart
runtimeState._startDayOffset = (findDayOffset(runtimeState.eventNow.timeStart, plannedStart) +
runtimeState.eventNow.dayOffset) as Day;
// backdate _startEpoch to when the event conceptually started
const timeElapsed = timeCore.elapsedTime(runtimeState.clock, plannedStart as TimeOfDay);
runtimeState._startEpoch = timeCore.addDuration(epoch, -timeElapsed as Duration);
// calculate currentDay from the backdated epoch
runtimeState.rundown.currentDay = (runtimeState._startDayOffset +
timeCore.daysSinceStart(runtimeState._startEpoch, epoch)) as Day;
return { eventId: runtimeState.eventNow.id, didStart: true };
}
@@ -4,6 +4,7 @@ import type { TimerType } from '../TimerType.type.js';
import type { TimeStrategy } from '../TimeStrategy.type.js';
import type { Trigger } from './Automation.type.js';
import type { EntryCustomFields } from './CustomFields.type.js';
import type { Day } from './Temporal.js';
export type EntryId = string;
@@ -77,7 +78,7 @@ export type OntimeEvent = OntimeBaseEvent & {
// !==== RUNTIME METADATA ====! //
revision: number;
delay: number; // calculated at runtime
dayOffset: number; // calculated at runtime
dayOffset: Day; // calculated at runtime
gap: number; // calculated at runtime
};
@@ -1,4 +1,4 @@
import type { OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone } from 'ontime-types';
import type { Day, OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone } from 'ontime-types';
import { EndAction, SupportedEntry, TimerType, TimeStrategy } from 'ontime-types';
export const event: Omit<OntimeEvent, 'id' | 'cue'> = {
@@ -24,7 +24,7 @@ export const event: Omit<OntimeEvent, 'id' | 'cue'> = {
parent: null,
revision: 0, // calculated at runtime
delay: 0, // calculated at runtime
dayOffset: 0, // calculated at runtime
dayOffset: 0 as Day, // calculated at runtime
gap: 0, // calculated at runtime
};